一、背景需求
客户端程通过透明代理访问远程服务器,代理需要以SNAT去修改源地址源端口,一般写法是Add SNAT、Connect、Del SNAT;
那么问题来了,加SNAT规则时需要 -s $ip --sport $port (避免多个客户端互相混淆),若正好代理机器上存在多个地址时,调用Connect之前Socket并不知道需要绑定哪个出口地址,那怎么获取到$ip、$port呢?
我的思路是需要在Connect动作之前,目的服务器地址是已知的,通过发送ICMP echo 来确定本机的出口地址;
二、相关知识
2.1 IP路由选择过程
假设当前Linux具备多个网卡若干个地址,那么在路由表上将存在各个网段的默认路由(route -n / netstat -nr);
《CCNA》P257-261中提到了一个最简单的IP路由选择的过程就是ping操作,大致步骤就是:
1)ICMP创建回应请求数据包,IP协议创建分组;
2)IP协议判断目的IP地址为本地网络还是远程网络;
3)若目的为远程网络,分组需要先发送给默认网关(以默认网关的MAC地址发送,帧的形式);
4)网关收到IP分组,检查IP目的地址是否匹配网关的路由表项,得到下一跳路径,若找不到相关表项则丢弃分组;
5)循环第4步骤,最后服务器收到分组(网络层)完成目的地址的匹配,生成一个新的有效荷载递交到ICMP;
6)上述ICMP需要成功返回到最初的客户端,完成一个PING的过程;
2.2 ICMP结构
ICMP包含在IP分组中,所以整体结构是20字节的IP头+8字节ICMP头+ICMP载荷
我们在ping程序中使用的是ICMP echo request / reply 信息,注意request.type=8,reply.type=0
三、编程实现
ping的编程发送三层IP分组时,需要用到原始套接字(raw socket),参考《UNP》书中的方法
“socket(PF_INET, SOCK_RAW, IPPROTO_ICMP”
int ping(char *dst_ip)
{
int ret = FAILURE;
int sd = 0;
char buf[SIZE_LINE_NORMAL] = {0};
struct ip *ip = NULL;
struct sockaddr_in dst_addr = {0};
struct icmp icmp_packet = {0};
struct timeval tm = {.tv_sec = 1, .tv_usec = 0};
fd_set rdfds;
if ( !dst_ip ) {
perror("NULL\n");
goto _E1;
}
sd = socket(PF_INET, SOCK_RAW, IPPROTO_ICMP);
if ( sd < 0 ) {
perror("socket\n");
goto _E1;
}
dst_addr.sin_family = AF_INET;
dst_addr.sin_addr.s_addr = inet_addr(dst_ip);
ret = gen_icmp_packet(&icmp_packet, 8, 1);
if ( SUCCESS != ret ) {
goto _E2;
}
ret = sendto(sd, &icmp_packet, sizeof(struct icmp), 0,
(struct sockaddr *)&dst_addr, sizeof(struct sockaddr_in));
if ( ret < 0 ) {
perror("sendto\n");
goto _E2;
}
printf("Send ping sucess!\n");
/* Timeout 1s to recv icmp */
FD_ZERO(&rdfds);
FD_SET(sd, &rdfds);
ret = select(sd + 1, &rdfds, NULL, NULL, &tm);
if ( -1 == ret && EINTR != errno ) {
/* if serial error */
perror("select fail\n");
goto _E2;
}
else if ( 0 == ret ) {
/* timeout */
perror("recv timeout\n");
ret = FAILURE;
goto _E2;
}
if ( FD_ISSET(sd, &rdfds) ) {
ret = recv(sd, buf, sizeof(buf), 0);
if ( ret <= 0 ) {
perror("recv\n");
goto _E2;
}
ip = (struct ip *)buf;
printf("from: %s\n", inet_ntoa(ip->ip_src));
printf(" to: %s\n", inet_ntoa(ip->ip_dst));
}
ret = SUCCESS;
_E2:
CLOSE_SOCK(sd);
_E1:
return ret;
}
以上该注意的是并不是发送出ICMP echo request 就结束了,别忘了需求是获取出口地址;
所以,又结合 select + recv 的方式,超时1秒去等待 ICMP echo reply,然后再获取出口地址;
由于未使用“setsockopt (..., IPPROTO_IP, IP_HDRINCL, ...);” ,由系统自动填充IP头,所以我们只需要 gen_icmp_packet 去填充 icmp内容即可;
int gen_icmp_packet(struct icmp *icmp_packet, int type, int seq)
{
if ( !icmp_packet ) {
perror("NULL\n");
return FAILURE;
}
icmp_packet->icmp_type = type;
icmp_packet->icmp_code = 0;
icmp_packet->icmp_cksum = 0;
icmp_packet->icmp_id = htons(getpid());
icmp_packet->icmp_seq = htons(seq);
gettimeofday((struct timeval *)icmp_packet->icmp_data, NULL);
icmp_packet->icmp_cksum = api_checksum16((unsigned short *)icmp_packet, sizeof(struct icmp));
return SUCCESS;
}
同时需要进行crc16的校验码
u16 api_checksum16(u16 *buffer, int size)
{
u32 cksum = 0;
if ( !buffer ) {
perror("NULL\n");
return 0;
}
while ( size > 1 ) {
printf("1. Cksum: 0x%08x + 0x%04x\n", cksum, *buffer);
cksum += *buffer++;
size -= sizeof(u16);
}
if ( size ) {
cksum += *(u8 *)buffer;
}
printf("2. Cksum: 0x%08x\n", cksum);
/* 32 bit change to 16 bit */
while ( cksum >> 16 ) {
cksum = (cksum >> 16) + (cksum & 0xFFFF);
printf("3. Cksum: 0x%08x\n", cksum);
}
return (u16)(~cksum);
}
所以,一个基础的ping命令就完成了。
四、总结
利用ICMP可以获取出口地址,透明代理就可以针对目的地址进行一个出口地址的缓存。
若ICMP不可达,也不一定表示ICMP request 未送达目的,是存在 ICMP reply 回不来的可能性的,那么又如何获取到出口地址呢?
是否有直接查找路由表的编程方法?
平行思考,Nginx upstream的时候,是不是也有类似的选路过程?