1 概述
原始套接字提供普通的TCP和UDP套接字所不提供的以下3个能力。
-
有了原始套接字,进程可以读与写ICMPv4、IGMPv4和iCMPv6等分组。举例来说,ping程序就使用原始套接字发送ICMP回射请求并接收ICMP回射回应。多播路由守护程序mrouted也使用原始套接字发送和接收ICMPv4分组。
这个能力还使得使用ICMP或IGMP构筑的应用程序能够完全作为用户进程处理,而不必往内核中额外添加编码。举例来说,路由器发现守护进程就如此构建。该程序处理内核完全不认识的两个ICMP消息(路由器通告和路由器征求)。
-
有了原始套接字,进程可以读写内核不处理其协议字段的IPv4数据报。大多数内核仅仅处理该字段值为1(ICMP)、2(IGMP)、6(TCP)和17(UDP)的数据报。然而为协议字段定义的值还有不少:IANA的“Protocol Numbers”注册处列出了所有值。举例来说,OSPF路由协议既不使用TCP也不使用UDP,而是通过手法协议字段为89的IP数据报而直接使用IP。实现OSPF的gated守护进程必须使用原始套接字读与写这些IP数据报,因为内核不知道如何处理协议字段为89的IPv4数据报。这个能力还延续到IPv6。
-
有了原始套接字,进程还可以使用IP_HERINCL套接字选项自行构造IPv4首部。这个能力可用于构造譬如TCP或UDP分组。
2 原始套接字创建
创建一个原始套接字步骤如下:
(1)把第二个参数指定为SOCK_RAW并调用socket函数,以创建一个原始套接字。第三个参数(协议)通常不为0。如下:
int sockfd;
sockfd = socket( AF_INET, SOCK_RAW, protocol );
只有超级用户才能创建原始套接字,这么做可防止普通用户往网络写出它们自行构造的IP数据报。
(2)可以在这个原始套接字上按以下方式开启IP_HDRINCL套接字选项:
const int on = 1;
if( setsockopt( sockfd, IPPROTO, IP_HDRINCL, &on, sizeof( on ) ) < 0 )
出错处理
(3)可以在这个原始套接字上调用bind函数,不过比较少见。bind函数仅仅设置本地地址,因为原始套接字不存在端口号的概念。就输出而言,调用bind设置的是将用于从这个原始套接字发送的所有数据报的源IP地址(只在IP_HDRINCL套接字选项未开启的前提下)。如果不掉用bind,内核就把源IP地址设置为外出接口的主IP地址。
(4)可以在这个原始套接字上调用connect函数,不过也比较少见。connect函数仅仅设置外出地址,同样因为原始套接字不存在端口号的概念。就输出而言,调用connect之后我们可以把sendto调用改为write或send调用,因为目的IP地址已经指定了。
3 原始套接字输出
原始套接字的输出遵循以下规则。
-
普通输出通过调用sendto或sendmsg并指定目的IP地址完成。如果套接字已经连接,那么也可以调用write、writev或send。
-
如果IP_HDRINCL套接字选项未开启,那么由进程让内核发送的数据的起始地址指的是IP首部之后的第一个字节,因为内核将构造IP首部并把它置于来自进程的数据之前。内核把所构造IPv4首部的协议字段设置成来自socket调用的第三个参数。
-
如果IP_HDRINCL套接字选项已开启,那么由进程让内核发送的数据的起始地址指的是IP首部的第一个字节。进程调用输出的函数写出的数据量必须包括IP首部的大小。整个IP首部由进程构造,不过(a)IPv4标识字段可置为0,从而告知内核设置该值,(b)IPv4首部校验和字段总是由内核计算并储存,(c)IPv4选项字段是可选的。
-
内核会对超出外出接口MTU的原始分组执行分片。
3 原始套接字输出
内核把哪些接收到的IP数据报传递到原始套接字?这里遵循如下规则。
-
接收到的UDP分组和TCP分组绝不传递到任何原始套接字。如果一个进程想要读取含有UDP分组或TCP分组的IP数据报,它就必须在数据链路层读取这些分组。
-
大多数ICMP分组在内核处理完其中的ICMP消息后传递到原始套接字。源自Berkeley的实现把不是回射请求、时间戳请求或地址掩码请求(这三类ICMP消息全由内核处理)的所有接收到的ICMP分组传递给原始套接字。
-
所有IGMP分组在内核完成处理其中的IGMP消息后传递到原始套接字。
-
内核不认识其协议字段的所有IP数据报传递到原始套接字。内核对这些分组执行的唯一处理是针对某些IP首部字段的最小验证:IP版本、IPv4首部校验和、首部长度以及目的IP地址。
-
如果某个数据报以片段形式到达,那么在它的所有片段均到达且重组出该数据报之前,不传递任何片段分组到原始套接字。
当内核有一个需传递到原始套接字的IP数据报时,它将检查所有进程上的所有原始套接字,以寻找所有匹配的套接字。每个匹配的套接字将被传递以该IP数据报的一个副本。内核队每个原始套接字执行如下3个测试,只有这3个测试结果均为真,内核才把接收到的数据报传递送到这个套接字。
-
如果创建这个原始套接字时指定了非0的协议参数(scoket的第三个参数),那么接收到的数据报的协议字段必须匹配该值,否则该数据报不递送到这个套接字。
-
如果这个原始套接字已由bind调用绑定了某个本地IP地址,那么接收到的数据报的目的IP地址必须匹配这个绑定地址,否则该数据报不递送到这个套接字。
-
如果这个原始套接字已由connect调用指定了某个外地IP地址,那么接收到的数据报的源IP地址必须匹配这个已连接地址,否则该数据报不递送到这个套接字。
无论何时往一个原始IPv4套接字递送一个接收到的数据报,传递到该套接字所在进程的都是包括IP首部在内的完整数据报。
4 ping程序
ping程序的操作非常简单,往某个IP地址发送一个ICMP回射请求,该节点则以一个ICMP回射回应响应。
图1-1是构成我们的ping程序的各个函数及调用关系的概貌。
图1-1 我们的ping程序中各个函数的概貌
程序分为两部分,一部分在一个原始套接字上读入收到的每个分组,显示ICMP回射回应,另一部分每隔一秒钟发送一个ICMP回射请求。第二部分由SIGAKRM信号每秒钟驱动一次。
下面给出了所有程序文件都包含的头文件ping.h。
// 我们包含基本的IPv4和ICMPv4头文件,定义一些全局变量以及各个函数原型
#include <netinet/in_systm.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#define BUFSIZE 1500
// globals
char sendbuf[ BUFSIZE ];
int datalen; // bytes of data following ICMP header
char *host;
int nsent; // add 1 for each sendto()
pid_t pid; //our PID
int sockfd;
int verbose;
// function prototypes
void init_v6( void );
void proc_v4( char *, ssize_t, strcut msghdr *, strcut timeval * );
void proc_v6( char *, ssize_t, strcut msghdr *, strcut timeval * );
void send_v4( void );
void send_v6( void );
void readloop( void );
void sig_alrm( int );
void tv_sub( strcut timeval *, strcut timeval * );
// 我们使用proto结构处理IPv4与IPv6之间的差异。这个结构包含3个函数指针、2个套接字地址结构指针、这2个套接字
// 结构的大小以及ICMP的协议值。全局指针变量pr将指向为IPv4或IPv6初始化的某个proto结构。
strcut proto
{
void ( *fproc )( char *, ssize_t, strcut msghdr *, strcut timeval * );
void ( *fsend )( void );
void ( *finit )( void );
strcut sockaddr *sasend; // sockaddr{} for send, from getaddrinfo
strcut sockaddr *sarecv; // sockaddr{} for receiving
socklen_t salen; // length of sockaddr{}s
int icmpproto; // IPPROTO_xxx value for ICMP
} *pr
// 包含定义IPv6和ICMPv6结构和常值的2个头文件。
#ifdef IPV6
#include <netinet/ip6.h>
#include <netinet/icmp6.h>
#endif
下面是main函数。
#include <ping.h>
// 为IPv4和IPv6分别定义一个proto结构。其中套接字地址结构指针成员均初始化为空指针,因为我们还不知道
// 最终使用的IPv4还是IPv6
struct proto proto_v4 =
{ proc_v4, send_v4, NULL, NULL, NULL, 0, IPPROTO_ICMP };
#ifdef IPV6
struct protoproto_v6 =
{ proc_v6, send_v6, init_v6, NULL, NULL, 0, IPPPROTO_ICMPV6 };
#endif
// 把随同回射请求发送的可选数据量设置为56个字节,由此产生84字节的IPv4数据报(包括20字节IPv4首部和8字节
// ICMP首部)或104字节的IPv6数据报。随同某个回射请求发送的任何数据必须在对应的回射回应中返送回来。我们
// 将在这个数据区的前8个字节存放本回射请求发送时刻的时间戳,然后在收到对应的回射回应之时使用返回来的时间戳
// 计算并显示RTT
int datalen = 56; // data that goes with ICMP echo request
int main( int agrc, char **argv )
{
int c;
struct addrinfo *ai;
char *h;
// 本程序唯一支持的命令行选项是-v,它可使我们显示接收到的大多数ICMP消息。(我们只显示属于本ping进程的
// ICMP回射回应。)建立SIGALRM信号的信号处理函数,我们江看到该信号一经启动将每秒钟发送一个ICMP回射请求。
opterr = 0; // don't want getopt() writing to stderr
while( ( c = getopt( argc, argv, " v " ) ) != -1 )
{
switch( c )
{
case ' v ':
verbose++;
case ' ? ':
printf( " unrecognized option: %c \n", c );
exit( 1 );
}
}
if( optind != argc - 1 )
printf( " usage: ping [ -v ] < hostname > " );
exit( 1 );
host = argv[ optind ];
pid = getpid() & 0xffff; // ICMP ID field is 16bits
signal( SIGALRM, sig_alrm );
// 在命令行参数中必须有一个主机名或IP地址数串,我们调用host_serv函数来处理它。返回的addrinfo结构中含有
// 协议族:或为AF_INET,或为AF_INET6。据此初始化全局指针变量pr,让它指向正确的proto结构。我们还调用
// IN6_IS_ADDR_V$MAPPED确认由host_serv返回的IPv6地址不是一个IPv4映射的IPv6地址,因为这样的地址尽管
// 是一个IPv6地址,发送给其主机的确实IPv4分组。(这种过情况下我们可以直接改用IPv4地址)把已由
// getaddrinfo函数分配的套接字地址结构用于发送,并另行分配一个同样大小的套接字地址结构用于接收
ai = host_serv( host, NULL, 0, 0 );
h = host_ntop_host( ai_ai_addr, ai->ai_addrlen );
printf( " PING %s ( %s ): %d data bytes\n ",
ai->ai_canonname ? ai->ai_canname : h, h, datalen );
if( ai->ai_family == AF_INET )
{
pr = &proto_v4;
#ifdef IPV6
}
else if( ai->ai_family == AF_INET6 )
{
pr = &proto_v6;
if( IN6_IS_ADDR_V4MAPPED( &( ( ( strcut sockaddr_in6 * ) ai->ai_addr ) ->sin6_addr ) ) )
printf( " cannot ping IPV4-mapped IPV6 address" );
exit( 1 );
#endif
}
else
printf( " unkown address family %d ", ai->ai_family );
exit( 1 );
pr->sasend = ai->ai_addr;
pr->sarecv = calloc( 1, ai->ai_addrlen );
pr->salen = ai->ai_addrlen;
// 调用readloop函数执行处理。
readloop();
exit( 0 );
}
void readloop( void )
{
int size;
char recvbuf[ BUFSIZE ];
char controlbuf[ BUFSIZE ];
struct msghdr msg;
struct iovec iov;
ssize_t n;
struct timeval tval;
// 创建一个合适协议的原始套接字。调用setuid把进程的有效用户ID设置为实际用户ID,适用于本程序的可执行文件
// 具有seruid到root的属性且以普通用户执行它的情形。运行本程序的进程必须拥有超级用户特权才能创建原始套接
// 字,不过既然套接字已经建立,该进程就可以放弃这个额外特权了。这类需要短暂拥有额外特权的程序最好是一旦不
// 再需要某个额外特权就放弃它,以防止程序中可能潜伏的缺陷被攻击者利用
sockfd = socket( pr->sasend->sa_family, SOCK_RAW, pr->icmpproto );
setuid( getuid() ); // don't need special permissions any more
// 如果所用协议有一个初始化函数,那就调用它。
if( pr->finit )
( *pr->finit )();
// 我们试图把套接字接收缓冲区大小设置为61440字节(60x1024),它应该比默认设置大。这么做可以防备用户对
// IPv4广播地址或某个多播地址执行ping,两者均可能产生大量的回应。套接字接收缓冲区设置越大,它发生一处的
// 可能性也就越小。
size = 60 * 1024; // OK if setsockopt fails
setsockopt( sockfd, SOL_SOCKET, SO_RCVBUF, &size, sizeof( size ) );
// 调用SIGALRM信号处理函数发送第一个分组。该函数除发送一个分组外,还调用下一个SIGALRM信号在1秒钟之后产生。
// 如此直接调用信号处理函数并不常见,不过可以接受。信号处理函数也是C函数,尽管他们通常是异步调用的。
sig_alrm( SIGALRM ); // send first packet
// 设置将传递给recvmsg的msghdr结构及iovec结构中的恒定成员
iov.iov_base = recvbuf;
iov.iov_len = sizeof( recvbuf );
msg.msg_name = pr->sarecv;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = controlbuf;
// 本程序的主循环是一无限循环,它读入返回的原始ICMP套接字的每个分组。我们调用gettimeofday记录分组收取时刻,
// 然后调用合适的协议函数(proc_v4或proc_v6)处理包含在该分组中的ICMP消息
for( ; ; )
{
msg.msg_namelen = pr->salen;
msg.msg_controllen = sizeof( controlbuf );
n = recvmsg( sockfd, &msg, 0 );
if( n < 0 )
{
if( errno == EINTR )
continue;
else
printf( " recvmsg error " );
exit( 1 );
}
gettimeofday( &tval, NULL );
( *pr->fproc )( recvbuf, n, &msg, &tval );
}
}
下面给出了tv_sub函数,它把两个timeval结构中存放的时间值相减,并把结果存入第一个timeval结构中。
void tv_sub( struct *out, struct timeval *in )
{
if( ( out->tv_usec -= in->tv_usec ) < 0 ) // out -= in
{
--out->tv_sec;
out->tv_usec += 100000;
}
out->tv_sec -= in->tv_sec;
}
下面是proc_v4函数,它处理所有接收到的ICMPv4消息。另外需知,当一个ICMPv4消息由进程在原始套接字上收取时,内核已经证实它的IPv4首部和ICMPv4首部中的基本字段的有效性。
void proc_v4( char * ptr, ssize_t len, struct msghdr *msg,
struct msghdr *msg, struct timeval *tvrecv )
{
int hlen, icmplen;
double rtt;
struct ip *ip;
struct icmp *icmp;
struct timeval *tvsend;
// 将IPv4首部长度字段乘以4得出IPv4首部以字节为单位的大小。(IPv4首部可能含有选项。)我们据此把Icmp设置
// 成指向ICMP首部的开始位置。我们确定IP协议是ICMP,而且有足够的回射数据来查看包含在回射请求中的时间戳。
ip = ( struct ip * ) ptr; // start of iIP header
hlenl = ip->ip_hl << 2; // length of IP header
if( ip->ip_p != IPPOTO_ICMP )
return; // not ICMP
icmp = ( struct icmp * ) ( ptr + hlenl ); // start of ICMP header
if( ( icmplen = len - henl ) < 8 )
return; // malformed packet
// 如果所处理的消息是一个ICMP回射回应,那么我们必须检查标识符字段,判定该回应是否响应于由本进程发出的请求。
// 如果本主机上同时运行着多个ping进程,那么每个进程都得到内核接收到的所有ICMP消息的一个副本。
if( icmp->icmp_type == ICMP_ECHOREPLY )
{
if( icmp->icmp_id != pid )
return; // not a response to our ECHO_REQUEST
if(ic,plem < 16)
return; // not enough data to use
// 通过从当前时间(由函数参数tvrecv指向)减去消息发送时间(包含在ICMP回应的可选数据部分中),我们计算出
// RTT。把RTT从微秒数转换成毫秒数之后,与序列号字段以及接收TTL一道显示输出。序列号字段使得用户能能够查看
// 是否发生过分组丢失、错序或重复,接收TTL则给出两个通信主机之间步跳数的某种指示。
tvsend = ( struct timeval * ) icmp->icmp_data;
tv_sub( tvrecv, tvsend );
rtt = tvrecv->tv_sec * 1000.0 + tvrecv->tv_usec / 1000.0;
printf( " %d bytes from %s: seq=%u, ttl=%d, rtt=%.3f ms\n ",
icmplen, sock_ntop_host( pr->sarecv, pr->salen ),
icmp->icmp_seq, ip->ip_ttl, ttl );
}
// 如果用户指定了-v(详尽输出)命令行选项,那就显示除回射回应外的所有接收ICMP消息的类型字段和代码字段
else if( verbose )
{
printf( " %d bytes from %s:type= %d, code = %d\n ", icmplen,
sock_ntop_host( pr->sarecv, pr->salen ), icmp->icmp_type, icmp->icmp_code );
}
}
下面是用于计算校验和的函数。
uint16_t in_cksum( uint16_t *addr, int len )
{
int nleft = len;
uint32_t sum = 0;
uint32_t *w = addr;
uint_16 ansewer = 0;
while( nleft > 1 )
{
sum += *w++;
nleft -= 2;
}
...
}