目前大多操作系统都为程序提供访问数据链路层的功能,此功能可提供以下能力:
1.能监视由数据链路层接收的分组,使得tcpdump之类的程序能运行,而无需专门的硬件设备来监视分组。如果结合使用网络接口进入混杂模式(promiscuous mode)的能力,那么应用甚至能监视本地电缆上流通的所有分组,而不仅仅是以程序运行所在主机为目的地的分组。
网络接口进入混杂模式的能力在日益普及的交换式网络(即使用交换机连接多个设备的计算机网络,在交换式网络中,交换机充当着网络通信的中心,交换机的每个接口都直接与一个主机相连)中用处不大,因为交换机仅仅把传给目的主机的单播、多播或广播分组传到目的主机的物理网络接口上。为了监视流经其他交换机端口的分组,连接到我们主机的交换机的端口必须配置成接收其他分组的,这称为监视器模式(monitor mode)或端口镜像(port mirroring)。有些你可能认为没有交换器的存储转发能力的设备实际也具有这种能力,如双速率10/100 Mbit/s集线器也可近似看成一个双端口的交换机,一个端口上连接100Mbit/s系统,另一个端口上连接10Mbit/s系统。
2.能够作为应用进程而非内核的一部分运行某些程序,如RARP服务器的大多数Unix版本是普通的应用进程,它们从数据链路读入RARP请求,又往数据链路写出RARP应答(RARP请求和应答都不是IP数据报)。
Unix上访问数据链路层的3个常用方法是BSD的分组过滤器BPF、SVR 4的数据链路提供者接口DLPI、Linux的SOCK_PACKET接口。我们先介绍这3个数据链路访问接口,然后讲解libpcap这个公开可得的分组捕获函数库,该函数库在这3个系统上都能工作,因此使用此函数库能使我们编写独立于操作系统提供的实际数据链路访问接口的程序。
4.4 BSD及源自Berkeley的许多其他实现都支持BSD分组过滤器(BSD Packet Filter,BPF),BPF的实现在TCPv2中有讲解。
发送一个分组之前或在接收一个分组之后会调用BPF:
TCPv2中给出了某个以太网接口驱动程序中调用BPF的例子。在分组接收后尽早调用BPF以及在发送分组前尽晚调用BPF的原因是为了提供精确的时间戳。
尽管往数据链路中安置一个用于捕获所有分组的代码并不困难,BPF强大在它的过滤能力,打开一个BPF设备的应用进程可以装载各自的过滤器,这个过滤器随后由BPF应用于每个分组,有些过滤器比较简单(如只接收UDP或TCP分组),但更复杂的过滤器可以检查分组首部某些字段是否为特定值,如以下过滤器:
tcp and port 80 and tcp[13:1] & 0x7 != 0
只收集去往或来自端口80的,设置了SYN、FIN、RST标志的TCP分节,其中表达式tcp[13:1]
指代从TCP首部开始位置起字节偏移量为13那个位置开始的1字节值。
BPF实现一个基于注册的过滤机器,该过滤机器对每个收到的数据包应用特定于应用程序的过滤。虽然可以用这个伪机器的机器语言编写过滤程序,但最简单的接口是使用pcap_compile函数把类似上面的ASCII字符串编译成BPF伪机器的机器语言。
BPF使用以下3个技术降低开销:
1.BPF过滤在内核中进行,以此把BPF到应用进程的数据复制量减少到最小。如果不在内核中过滤,需要从内核空间到用户空间的复制分组,这种复制开销高昂,如果每个分组都这么复制,BPF可能跟不上快速的数据链路。
2.由BPF传递到应用进程的只是每个分组的一段定长部分,这个长度称为捕获长度(capture length),也称为快照长度(snapshot length,简写为snaplen)。大多应用进程只需要分组首部而不需要分组数据,这个技术减少了由BPF复制到应用进程的数据量,例如,tcpdump默认把该值设置为96字节,能容纳一个14字节的以太网首部、一个40字节的IPv6首部、一个20字节的TCP首部以及22字节的数据,如果需要显示来自其他协议(如DNS或NFS)的额外信息,用户就得在运行tcpdump时增大该值。
3.BPF为每个应用进程分别缓冲数据,只有当缓冲区已满或读超时时,该缓冲区中的数据才复制到应用进程,该超时值可由应用进程指定,例如tcpdump把它设置为1000ms,RARP守护进程把它设置为0(因为RARP分组极少,且RARP服务器需要一接收请求就发送应答)。如此缓冲的目的在于减少系统调用的次数。尽管从BPF复制到应用进程的仍然是相同数量的分组,但每次系统调用都有一定的开销,因而减少系统调用次数就能降低开销。
尽管我们在图29-1中只显示了一个缓冲区,BPF其实为每个应用进程维护两个缓冲区,在其中一个缓冲区中的数据被复制到应用进程期间,另一个缓冲区被用于装填数据,这就是标准的双缓冲技术。
我们在图29-1中只显示了BPF的分组接收,包括由数据链路从下方(网络)接收的分组和由数据链路从上方(IP)接收的分组。应用进程也可以写往BPF,使分组通过数据链路往外(向上或向下)发送出去,但大多数应用进程仅仅读BPF。没有理由通过写往BPF发送IP数据报,因为IP_HDRINCL套接字选项允许我们写出任何期望的IP数据报(包括IP首部)。写往BPF的唯一理由是为了自行发送不是IP数据报的网络分组,如RARP守护进程就如此发送不是IP数据报的RARP应答。
为了访问BPF,我们必须打开一个当前关闭着的BPF设备,例如,我们可以尝试打开/dev/bpf0,如果返回EBUSY错误,就尝试打开/etc/bpf1,一旦打开一个BPF设备,我们可以使用一些ioctl命令设置该设备的特征,包括装载过滤器、设置读超时、设置缓冲区大小、将一个数据链路(即网络接口)连接到BPF设备、启用混杂模式等,然后就使用read和write函数执行IO。
SVR 4通过数据链路提供者接口(Datalink Provider Interface,DLPI)提供数据链路访问,DLPI是一个由AT&T设计的独立于协议的访问数据链路层所提供服务的接口,其访问通过发送和接收流消息(STREAMS message)实施。
DLPI有两种打开风格:一种是应用进程先打开一个设备,然后通过DLPI的DL_ATTACH_REQ请求要使用的网络接口;另一种是直接打开某个网络接口设备(如le0)。为了提升效率,需要压入2个流模块(STREAMS module):在内核中进行分组过滤的pfmod模块和为应用进程缓冲数据的bufmod模块:
从概念上来说,这两个模块类似BPF开销降低的技术:pfmod在内核中使用伪机器支持过滤;bufmod通过支持快照长度和读取超时来减少数据量和系统调用次数。
然而,一个有趣的区别在于BPF和pfmod过滤器支持的伪机器类型。BPF过滤器是一个有向无环控制流图,而pfmod则使用布尔表达式树。前者自然地映射为寄存器型机器代码,而后者自然地映射为堆栈型机器代码[McCanne and Jacobson 1993]。该论文表明,BPF使用的CFG实现通常比布尔表达式树快3到20倍,具体取决于过滤器的复杂性。
另外,BPF总是在复制分组前作出过滤决策,以避免复制过滤器将会丢弃的数据包。根据DLPI的实现,数据包可能会被复制给pfmod,然后可能会被pfmod丢弃。
Linux先后有两个从数据链路层接收分组的方法。较旧的方法是创建类型为SOCK_PACKET的套接字,此方法更普适但缺乏灵活性;较新的方法创建协议族为PF_PACKET的套接字,这个方法引入了更过的过滤和性能特性。我们需要有足够的权限才能创建这两种套接字,且调用socket的第三个参数必须是指定以太网帧的某个非0值。创建PF_PACKET套接字时,调用socket的第二个参数既可以是SOCK_DGRAM,表示扣除链路层首部的帧,也可以是SOCK_RAW,表示完整的链路层帧。SOCK_PACKET套接字只返回完整的链路层帧。以下方式可以从数据链路接收所有帧:
fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); // 较新方法
fd = socket(AF_INET, SOCK_PACKET, htons(ETH_P_ALL)); // 较旧方法
如果只想捕获IPv4帧:
fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP)); // 较新方法
fd = socket(AF_INET, SOCK_PACKET, htons(ETH_P_IP)); // 较旧方法
用作socket调用的第三个参数的常值还有ETH_P_ARP、ETH_P_IPV6等,它们告知数据链路应该把接收到的哪些类型的帧传递给所创建的套接字。如果数据链路支持混杂模式(如以太网),如果需要的话可以将设备改为混杂模式。对于PF_PACKET套接字,把一个网络接口改为混杂模式可通过PACKET_ADD_MEMBERSHIP套接字选项完成,此时setsockopt函数的第四个参数的类型为packet_mreq,在此结构中指定网络接口以及PACKET_MR_PROMISC行为;对于SOCK_PACKET套接字,改为混杂模式需要使用SIOCGIFFLAGS标志调用ioctl以获取标志,然后将IFF_PROMISC加入获取到的标志,再以SIOCSIFFLAGS调用ioctl存储标志,不幸的是,若采用此方法,多个程序同时设置混杂模式时可能会互相干扰,且有缺陷的程序可能在退出后还保持着混杂模式。
Lunix的数据链路访问方法与BPF和DLPI存在如下差别:
1.Linux方法不提供内核缓冲,且只有较新的方法才提供内核过滤(需要用SO_ATTACK_FILTER套接字选项安装),尽管这些套接字有普通的套接字接收缓冲区,但多个帧不能缓冲在一起由单个读操作一次性地传递给应用进程。这样会增加从内核到应用进程复制的数据的开销。
2.Linux较旧的方法不提供针对设备的过滤,较新的方法可通过调用bind与某个设备关联。如果调用socket时指定了ETH_P_IP,那么来自任何设备(如以太网、PPP链路、环回设备)的所有IPv4分组都被传递到所创建的套接字。recvfrom函数将返回一个通用套接字地址结构,其中的sa_data成员含有设备名字(如eth0),应用进程必须自行丢弃来自不关注的设备的数据。这里仍然会有太多数据返回到应用进程,从而妨碍对于高速网络的监视。
libpcap是访问操作系统所提供的分组捕获机制的分组捕获函数库,它是与实现无关的。目前它只支持分组的读入(当然只需往该函数库中增加一些代码行就可以让调用者往数据链路写出分组)。libnet函数库不仅支持往数据链路写分组,还能构造任意协议的分组。
libpcap目前支持源自Berkeley内核中的BPF、Solaris 2.x和HP-UX中的DLPI、SunOS 4.1.x中的NIT(网络接口层,Network Interface Layer)、Linux的SOCK_PACKET套接字和PF_PACKET套接字,以及若干其他操作系统。tcpdump就使用该函数库。libpcap由大约25个函数组成,我们稍后给出使用其中常用函数的一个例子,所有库函数均以pcap_前缀打头。
libpcap函数库可从http://www.tcpdump.org
公开获取。
libnet函数库可构造任意协议的分组并将其输出到网络中的接口,它以与实现无关的方式提供原始套接字访问方式和数据链路访问方式。
libnet隐藏了构造IP、UDP、TCP首部的许多细节,并提供简单且便于移植的数据链路和原始套接字写出访问接口。稍后给出一些libnet库函数的使用例子。libnet的所有库函数均以libnet_前缀打头。
现开发一个程序,它向一个名字服务器发送含有某个DNS查询的UDP数据报,然后使用分组捕获函数库读入应答,确定这个名字服务器是否计算UDP校验和。对于IPv4,UDP校验和的计算是可选的,如今大多系统默认开启校验和,但较老系统(如SunOS 4.1.x)默认禁止校验和。当今所有系统(特别是运行名字服务器的系统)都总是应该开启UDP校验和,否则DNS服务器收到的受损数据报可能破坏DNS服务器的数据库,存入错误的信息。
开启和禁止UDP校验和通常是基于系统范围设置的。
我们将自行构造UDP数据报(DNS查询),并把它写到一个原始套接字,这个查询使用普通的UDP套接字就可发送,但我们想展示如何使用IP_HDRINCL套接字选项构造一个完整的IP数据报。
并且,我们无法在从普通UDP套接字读入时获取UDP校验和,UDP或TCP分组也不会传到原始套接字,因此我们必须使用分组捕获机制获取含有名字服务器的应答的完整UDP数据报。
我们会检查所获取UDP首部中的校验和字段,如果其值为0,那么该名字服务器没有开启UDP校验和。
我们把自行构造的UDP数据报写出到原始套接字,然后使用libpcap读回其应答。UDP模块也接收到这个来自名字服务器的应答,并将响应以一个ICMP端口不可达错误,因为UDP模块根本不知道我们自行构造的UDP数据报选用的端口号。名字服务器将忽略这个ICMP错误。使用TCP编写一个这样的测试程序比较困难,尽管我们可以很容易地把构造的TCP分节写出到网络,但对于此分节的应答我们的TCP模块响应以一个RST,结果是连三路握手都完成不了。
绕过这个难题的方法之一是以发送主机所在子网上某个未使用的IP地址为源地址发送TCP分节,且事先在发送主机上为这个未使用IP地址增加一个ARP表项,使得发送主机能回答这个未使用地址的ARP请求,但不把这个未使用IP地址作为别名地址配置在发送主机上,这将导致发送主机上的IP协议栈丢弃所接收的目的地址为未使用地址的分组,前提是发送主机不用作路由器。
以下是构成udpcksum程序的函数:
以下是udpcksum.h头文件:
#include "unp.h"
#include <pcap.h>
#include <netinet/in_systm.h> /* required for ip.h */
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_var.h>
#include <netinet/udp.h>
#include <netinet/udp_var.h>
#include <net/if.h>
#include <netinet/if_ether.h>
#define TTL_OUT 64 /* outgoing TTL */
/* declare global variables */
extern struct sockaddr *dest, *local;
extern socklen_t destlen, locallen;
extern int datalink;
extern char *device;
extern pcap_t *pd;
extern int rawfd;
extern int snaplen;
extern int verbose;
extern int zerosum;
/* function prototypes */
void cleanup(int);
char *next_pcap(int *);
void open_output(void);
void open_pcap(void);
void send_dns_query(void);
void test_udp(void);
void udp_write(char *, int);
struct udpiphdr *udp_read(void);
以下是udpcksum的main函数:
#include "udpcksum.h"
/* define global variables */
struct sockaddr *desc, *local;
struct sockaddr_in locallookup;
socklen_t destlen, locallen;
int datalink; /* from pcap_datalink(), in <net/bpf.h> */
char *device; /* pcap device */
pcap_t *pd; /* packet capture struct pointer */
int rawfd; /* raw socket to write on */
int snaplen = 200; /* amount of data to capture */
int verbose;
int zerosum; /* send UDP query with no checksum */
static void usage(const char *);
int main(int argc, char *argv[]) {
int c, lopt = 0;
char *ptr, localname[1024], *localport;
struct addrinfo *aip;
opterr = 0; /* don't want getopt() writing to stderr */
// getopt函数可以接受数字选项,如此处的0
while ((c = getopt(argc, argv, "0i:l:v")) != -1) {
switch (c) {
// -0选项要求不设置UDP校验和就发送UDP查询,以便查看服务器对它的处理是否不同于设置了校验和的数据报
case '0':
zerosum = 1;
break;
// -i选项用于指定接收服务器的应答的接口,如果接口未指定,分组捕获函数库将会选择一个
// 但函数库选定的接口在多宿主机上可能不是即将接收DNS应答的接口
// 从分组捕获设备读入与从普通套接字读入的差别之一就体现在此:
// 使用套接字我们可以使用通配地址,从而接收到达任意接口的分组
// 但使用分组捕获设备就只能在单个接口上接收到达的分组
// Linux的SOCK_PACKET方法没有把它的数据链路捕获限定在单个设备
// 尽管如此,libpcap却基于其默认设置或我们的-i选项提供限定接口形式的过滤
case 'i':
device = optarg; /* pcap device */
break;
// -l选项用于指定源IP地址和源端口号,本选项的参数中,端口号(或服务名)是最后一个点号之后的部分
// 源IP地址是组后一个点号之前的部分
case 'l': /* local IP address and port #: a.b.c.d.p */
if ((ptr = strrchr(optarg, '.')) == NULL) {
usage("invalid -l option");
}
*ptr++ = 0; /* null replaces final period */
localport = ptr; /* service name or port number */
strncpy(localname, optarg, sizeof(localname));
lopt = 1;
break;
case 'v':
verbose = 1;
break;
case '?':
usage("unrecognized option");
}
}
// 剩余命令行参数必须恰好是两个:运行DNS服务器的目的主机名(或目的IP)和服务名(或端口号)
if (optind != argc - 2) {
usage("missing <host> and/or <serv>");
}
/* convert destination name and service */
// 调用我们的host_serv将目的主机名(或目的IP)和服务名(或端口号)转换成套接字地址结构
aip = Host_serv(argv[optind], argv[optind + 1], AF_INET, SOCK_DGRAM);
dest = aip->ai_addr; /* don't freeaddrinfo() */
destlen = aip->ai_addrlen;
/*
* Need local IP address for source IP address for UDP datagrams.
* Can't specify 0 adn let IP choose, as we need to know it for
* the pseudoheader to calculate the UDP checksum.
* If -l option supplied, then use those valuse; otherwise,
* connect a UDP socket to the destination to determine the
* right source address.
*/
// 我们自定构造UDP首部,因此我们在写出该UDP数据报前必须知道源IP地址,我们不能让IP模块为其选择值
// 因为源IP地址是UDP伪首部的一部分,计算UDP校验和时会使用伪首部
// 如果有-l选项,将本地地址和端口转换为套接字地址结构
if (lopt) {
/* convert local name and service */
aip = Host_serv(localname, localport, AF_INET, SOCK_DGRAM);
local = aip->ai_addr; /* don't freeaddrinfo() */
locallen = aip->ai_addrlen;
// 否则通过把一个UDP套接字连接到目的地确定内核选定的本地IP地址和临时端口号
} else {
int s;
s = Socket(AF_INET, SOCK_DGRAM, 0);
Connect(s, dest, destlen);
/* kernel chooses correct local address for dest */
locallen = sizeof(locallookup);
local = (struct sockaddr *)&locallookup;
Getsockname(s, local, &locallen);
if (locallookup.sin_addr.s_addr == htonl(INADDR_ANY)) {
err_quit("Can't determine local address - use -l\n");
}
close(s);
}
// 调用open_output创建一个原始套接字并开启IP_HDRINCL套接字选项
// 我们于是可以往这个套接字写出包括IP首部在内的完整IP数据报
// open_output函数还有一个使用libnet实现的版本
open_output(); /* open output, either raw socket or libnet */
// 调用open_pcap打开分组捕获设备
open_pcap(); /* open packet capture device */
// 创建原始套接字和打开分组捕获设备都需要超级用户特权,但具体取决于实现
// 如对于BPF,管理员可设置/dev/bpf设备的访问权限
// 既然已经完成特权操作,我们此处放弃这个特权,假定这个特权是通过设置用户id而获取的
// 具有超级用户特权的进程调用setuid把它的实际用户ID、有效用户ID、保存的设置用户ID都设为当前的实际用户ID
setuid(getuid()); /* don't need superuser privileges anymore */
// 防止用户在程序运行完前强行终止它
Signal(SIGTERM, cleanup);
Signal(SIGINT, cleanup);
Signal(SIGHUP, cleanup);
// test_udp函数发送一个DNS查询,并读入服务器的应答
test_udp();
// cleanup函数显示来自分组捕获函数库的统计结果后终止进程
cleanup(0);
}
open_pcap函数由main函数调用以打开分组捕获设备:
#include "udpcksum.h"
#define CMD "udp and src host %s and src port %d"
void open_pcap(void) {
uint32_t localnet, netmask;
char cmd[MAXLINE], errbuf[PCAP_ERRBUF_SIZE], str1[INET_ADDRSTRLEN], str2[INET_ADDRSTRLEN];
struct bpf_program fcode;
// 如果没有指定分组捕获设备(通过-i命令行选项),就调用pcap_lookupdev选择一个设备
// pcap_lookupdev函数以SIOCGIFCONF为参数调用ioctl,找到索引号最小的UP状态的接口设备(除环回接口外)
if (device == NULL) {
// 许多pcap库函数在出错时填写一个出错消息串
// 传给pcap_lookupdev函数的唯一参数就是一个用于填写出错消息的字符数组
if ((device = pcap_lookupdev(errbuf)) == NULL) {
err_quit("pcap_lookup: %s", errbuf);
}
}
printf("device = %s\n", device);
/* hardcode: promisc=0, to_ms=500 */
// 调用pcap_open_live打开这个设备,函数名中的live表明所打开的是一个真实设备
// 而不是一个含有先前保存的分组的文件
// device参数是设备名,snaplen参数是每个分组保存的字节数,第三个参数为是否设置混杂模式
// 第四个参数为以毫秒为单位的超时值,第五个参数是指向用于返回出错字符串的字符数组指针
// 如果设置了混杂模式,网络接口就被投入混杂模式,导致它接收电缆上流经的所有分组
// 对于tcpdump混杂模式是通常的模式,但对于我们的例子,来自DNS服务器的应答会被发送到本主机,因此无需设置混杂模式
// 超时参数指读超时,如果每收到一个分组就让设备把该分组返送到应用进程,会引起从内核到应用进程的大量个体分组复制
// 因此效率比较低,libpcap仅当设备的读缓冲区被填满或读超时发生时才返送分组
// 如果超时值被设为0,则每个分组一经接收就被返送
if ((pd = pcap_open_live(device, snaplen, 0, 500, errbuf)) == NULL) {
err_quit("pcap_open_live: %s", errbuf);
}
// pcap_lookupnet函数返回分组捕获设备的网络地址和子网掩码
// 我们接下来调用pcap_compile时必须指定这个子网掩码
// 因为分组过滤器需要用子网掩码判断一个IP地址是否为一个子网定向广播地址
if (pcap_lookupnet(device, &localnet, &netmask, errbuf) < 0) {
err_quit("pcap_lookupnet: %s", errbuf);
}
if (verbose) {
printf("localnet = %s, netmask = %s\n", Inet_ntop(AF_INET, &localnet, str1, sizeof(str1)),
Inet_ntop(AF_INET, &netmask, str2, sizeof(str2)));
}
snprintf(cmd, sizeof(cmd), CMD, Sock_ntop_host(dest, destlen),
ntohs(sock_get_port(dest, destlen)));
if (verbose) {
printf("cmd = %s\n", cmd);
}
// pcap_compile函数把我们在cmd字符数组中构造的过滤器字符串编译成一个过滤器程序
// 将其存放在fcode中,这个过滤器将选择我们希望接收的分组
if (pcap_compile(pd, &fcode, cmd, 0, netmask) < 0) {
err_quit("pcap_compile: %s", pcap_geterr(pd));
}
// pcap_setfilter函数把我们刚编译出来的过滤器程序装载到分组捕获设备
if (pcap_setfilter(pd, &fcode) < 0) {
err_quit("pcap_setfilter: %s", pcap_geterr(pd));
}
// pcap_datalink函数返回分组捕获设备的数据链路类型,接收分组时我们根据该值确定数据链路首部大小
if ((datalink = pcap_datalink(pd)) < 0) {
err_quit("pcap_datalink: %s", pcap_geterr(pd));
}
if (verbose) {
printf("datalink = %d\n", datalink);
}
}
test_udp函数发送一个DNS查询,并读入服务器的应答:
void test_udp(void) {
// 我们希望这两个自动变量从信号处理函数siglongjmp到本函数前后值保持不变
// 加上volatile限定词可以防止编译器优化导致跳回后nsent当做初始值0使用(因为从定义到使用看起来没有修改过它的值)
volatile int nsent = 0, timeout = 3;
struct udpiphdr *ui;
Signal(SIGALRM, sig_alrm);
// 首次调用sigsetjmp时,它返回0,从siglongjmp函数跳回时,它返回1
// sigsetjmp函数的第二个参数非0时,会将当前的信号屏蔽字保存在jmpbuf参数中
// 从而从siglongjmp函数跳回时恢复信号屏蔽字
// 进入信号处理函数时,会将该信号信号加入屏蔽字,从而跳回来时恢复信号屏蔽字
if (sigsetjmp(jmpbuf, 1)) {
// 进入此处说明是从SIGALRM信号处理函数中调用siglongjmp跳转回来的
// 即我们发送了一个请求,但没有收到应答,从而超时导致进入SIGALRM信号处理函数,然后跳转回来
// 如果3次请求都超时,则终止进程
if (nsent >= 3) {
err_quit("no response");
}
// 否则显示一条消息并倍增超时值(通过指数回退增加)
printf("timeout\n");
// timeout的初始值为3,表示首次超时值为3秒,然后依次是6秒、12秒
timeout *= 2; /* exponential backoff: 3, 6, 12 */
}
// 我们像这样使用sigsetjmp和siglongjmp函数,而非简单地判断读函数是否错误返回EINTR
// 是因为分组捕获函数库的读函数(由我们的udp_read函数调用)在read函数返回EINTR时重启读操作
// 而我们不想为了返回EINTR错误而修改库函数,唯一的解决方法是捕获SIGALRM信号并执行一个非本地的长跳转
// 从而让控制流返回到本函数,而非库函数中
// 信号处理函数建立后和sigsetjmp首次调用前,SIGALRM信号也有可能被递交,因此此时再打开该标志
// 即使程序本身不会导致产生SIGALRM信号,它也可能通过其他方式产生,如使用kill命令
canjump = 1; /* siglongjmp is now OK */
// send_dns_query函数向DNS服务器发送一个DNS查询
send_dns_query();
++nsent;
// udp_read函数用于读入DNS服务器的应答,读应答前先调用alarm防止读操作永远阻塞
// 超时时,内核将产生SIGALRM信号,而我们的信号处理函数会调用siglongjmp
alarm(timeout);
ui = udp_read();
canjump = 0;
alarm(0);
if (ui->ui_sum == 0) {
printf("UDP checksums off\n");
} else {
printf("UDP checksums on\n");
}
if (verbose) {
printf("received UDP checksum = %x\n", ntohs(ui->ui_sum));
}
}
以下是我们的SIGALRM的信号处理函数sig_alrm,以下内容与test_udp函数放在同一文件:
#include "udpcksum.h"
#include <setjmp.h>
static sigjmp_buf jmpbuf;
static int canjump;
void sig_alrm(int signo) {
// canjmp是test_udp函数中初始化跳转缓冲区后设置的,并在读入应答后清除
if (canjmp == 0) {
return;
}
siglongjmp(jmpbuf, 1);
}
以下send_dns_query函数构造一个DNS查询,并通过原始套接字把该UDP数据报发送给名字服务器:
void send_dns_query(void) {
size_t nbytes;
char *buf, *ptr;
// 分配缓冲区,它足以存放20字节IP首部、8字节UDP首部、100字节用户数据
buf = Malloc(sizeof(struct udpiphdr) + 100);
// ptr指向用户数据的第一个字节
ptr = buf + sizeof(struct udpiphdr); /* leave room for IP/UDP headers */
// DNS标识字段设为1234
*((uint16_t *)ptr) = htons(1234); /* identification */
ptr += 2;
// DNS标志字段
*((uint16_t *)ptr) = htons(0x0100); /* flags: recursion desired */
ptr += 2;
// DNS问题数字段为1,表示DNS查询中包含1个问题
*((uint16_t *)ptr) = htons(1); /* # questions */
ptr += 2;
// 把回答的RR数、权威RR数、额外RR数都设为0
*((uint16_t *)ptr) = 0; /* # answer RRs */
ptr += 2;
*((uint16_t *)ptr) = 0; /* # authority RRs */
ptr += 2;
*((uint16_t *)ptr) = 0; /* # additional RRs */
ptr += 2;
// 查询a.root-servers.net的IP地址
// \001是1个8进制字节,表示此标签长度为1个字节,其他8进制字节同理
memcpy(ptr, "\001a\012root-servers\003net\000", 20);
ptr += 20;
// DNS查询类型为A查询
*((uint16_t *)ptr) = htons(1); /* query type = A */
ptr += 2;
*((uint16_t *)ptr) = htons(1); /* query class = 1 (IP addr) */
ptr += 2;
// 这个消息由36字节的用户数据构成(8个2字节字段和1个20字节域名)
nbytes = (ptr - buf) - sizeof(struct udpiphdr);
// 调用我们的udp_write构造UDP和IP首部,并把构造完的IP数据报写到原始套接字
udp_write(buf, nbytes);
if (verbose) {
printf("sent: %s bytes of data\n", nbytes);
}
}
以下是open_output函数:
// 存放原始套接字描述符的全局变量
int rawfd; /* raw socket to write on */
void open_output(void) {
int on = 1;
/*
* Need a raw socket to write our own IP datagrams to.
* Process must have superuser privileges to create this socket.
* Also must set IP_HDRINCL so we can write our own IP headers.
*/
rawfd = Socket(dest->sa_family, SOCK_RAW, 0);
// 开启IP_HDRINCL套接字选项,该选项允许我们往套接字写出包括IP首部在内的完整IP数据报
Setsockopt(rawfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));
}
以下udp_write函数构造IP和UDP首部,并把结果数据报写出到原始套接字,以下内容与open_output放在同一文件中:
void udp_write(char *buf, int userlen) {
struct udpiphdr *ui;
struct ip *ip;
/* fill in and checksum UDP header */
// ip指向IP首部的开始位置,ui也指向相同位置,但udpiphdr结构是IP和UDP首部的组合
ip = (struct ip *)buf;
ui = (struct udpiphdr *)buf;
// 显式清0首部区域,以免可能留在缓冲区中的剩余数据影响校验和的计算
// 此处的早先版本显式清零udpiphdr结构中的每个成员,但该结构有一些实现相关的细节,不同系统之间会有差异
// 在显式构造首部时,这是一个典型的移植性问题
bzero(ui, sizeof(*ui));
// ui_len是UDP首部字节数(8字节)加上UDP用户数据字节数,此值就是UDP首部中的长度字段值
ui->ui_len = htons((uint16_t)(sizeof(struct udphdr) + userlen));
/* then add 28 for IP datagram length */
// userlen是整个IP数据报的长度,包括IP首部
// 其值为UDP首部之后的UDP用户数据字节数加上28字节(20字节IP首部+8字节UDP首部)
userlen += sizeof(struct udpiphdr);
// UDP校验和计算不仅涵盖UDP首部和UDP数据,还涉及来自IP首部的若干字段,这些来自IP首部的字段构成伪首部
// 校验和计算涵盖伪首部能提供如下额外验证:如果校验和正确,则数据报确实已递送到正确的主机和正确的协议处理代码
// 从此处开始到ui_ulen的赋值为值,都是构成伪首部的字段
ui->ui_pr = IPPROTO_UDP;
ui->ui_src.s_addr = ((struct sockaddr_in *)local)->sin_addr.s_addr;
ui->ui_dst.s_addr = ((struct sockaddr_in *)dest)->sin_addr.s_addr;
ui->ui_sport = ((struct sockaddr_in *)local)->sin_port;
ui->ui_dport = ((struct sockaddr_in *)dest)->sin_port;
ui->ui_ulen = ui->ui_len;
// 如果计算校验和(即没有设置-0命令行参数)
if (zerosum == 0) {
#if 1 /* change to if 0 for Solaris 2.x, x < 6 */
// 如果计算出的校验和为0,就改为存入0xffff,在一的补数(one's complement)中这两个值是同义的
// UDP通过设置校验和为0值指示发送者没有存放UDP校验和
// 在第二十八章中,我们没有检查计算出的校验和是否为0,因为ICMPv4校验和是必需的,其值为0不指示没有校验和
if ((ui->ui_sum = in_cksum((u_int16_t *)ui, userlen)) == 0) {
ui->ui_sum = 0xffff;
}
// Solaris 2.x(x<6)对于通过设置了IP_HDRINCL套接字选项的原始套接字发送的TCP分节或UDP数据报而言
// 在校验和字段上有一个缺陷,这些校验和由内核计算,但进程必须把ui_sum成员设置为TCP或UDP的长度
#else
ui->ui_sum = ui->ui_len;
#endif
}
/* fill in rest of IP header */
/* ip_output() calculates & stores IP header checksum */
// 既然开启了IP_HDRINCL套接字选项,我们就要手动填写IP首部中的大多数字段
ip->ip_v = IPVERSION;
ip->ip_hl = sizeof(struct ip) >> 2;
ip->ip_tos = 0;
// ip_len成员需要根据所用系统决定按主机字节序设置还是网络字节序设置,这是使用原始套接字时的一个移植性问题
#if defined(linux) || defined(__OpenBSD__)
ip->ip_len = htons(userlen); /* network byte order */
#else
ip->ip_len = userlen; /* host byte order */
#endif
// 把IP首部的标识字段设为0,以告知IP模块去设置这个字段,主机每发送一份IP数据报,标识字段的值就会加1
// 如果IP数据报需要进行分片发送,则每个分片的IP首部标识字段都是一致的
// IP模块还会计算IP首部校验和
ip->ip_id = 0; /* let IP set this */
/* frag offset, MF and DF flags */
// MF是More Fragments的简称,值为1代表后面还有分片的数据报,值为0代表当前数据报已是最后一个分片
// DF是Don't Fragment的简称,表示不能对IP数据报进行分片
ip->ip_off = 0;
ip->ip_ttl = TTL_OUT;
Sendto(rawfd, buf, userlen, 0, dest, destlen);
}
以下是udp_read函数,它从分组捕获设备读入下一个分组:
struct udpiphdr *udp_read(void) {
int len;
char *ptr;
struct ether_header *eptr;
for (; ; ) {
// 调用我们的next_pcap函数从分组捕获设备获取下一个分组
ptr = next_pcap(&len);
// 既然数据链路首部依照实际设备类型存在差异,我们根据pcap_datalink函数返回的datalink变量选择分支
switch (datalink) {
case DLT_NULL: /* loopback header = 4 bytes */
return udp_check(ptr + 4, len - 4);
// 虽然名字里有10MB限定词,这个数据链路类型也用于100 Mbit/s以太网
case DLT_EN10MB:
eptr = (struct ether_header *)ptr;
if (ntohs(eptr->ether_type) != ETHERTYPE_IP) {
err_quit("Ethernet type %x not IP", ntohs(eptr->ether_type));
}
return udp_check(ptr + 14, len - 14);
// SLIP(Serial Line Internet Protocol)链路利用串行端口发送和接收IP数据包
case DLT_SLIP: /* SLIP header = 24 bytes */
return udp_check(ptr + 24, len - 24);
case DLT_PPP: /* PPP header = 24 bytes */
return udp_check(ptr + 24, len - 24);
default:
err_quit("unsupported datalink (%d)", datalink);
}
}
}
以上函数中所示的针对SLIP和PPP的24字节偏移量适用于BSD/OS 2.1版本。
以下是next_pcap函数,它返回来自分组捕获设备的下一个分组:
char *next_pcap(int *len) {
char *ptr;
struct pcap_pkthdr hdr;
/* keep looking until packet ready */
// 库函数pcap_next或者返回下一个分组,或者因超时返回NULL
// 我们在一个循环中调用pcap_next,直到返回一个分组(或者被SIGALRM信号中断,从而在信号处理函数中跳回test_udp函数)
// pcap_next函数的返回值是指向所返回分组的一个指针,它的第二个参数指向的pcap_pkthdr结构也在返回时被填写
while ((ptr = (char *)pcap_next(pd, &hdr)) == NULL);
// 捕获到的数据长度通过len参数指针返回给调用者,本函数的返回值则是指向所捕获分组的指针
*len = hdr.caplen; /* capture length */
// 函数返回值指向的数据链路首部,对于以太网帧是14字节的以太网首部,对于环回接口是4字节的伪链路首部
return ptr;
}
pcap_next函数返回分组时填写的pcap_pkthdr结构:
ts成员是分组捕获设备读入该分组的时间,而不是该分组真正递送到进程的时间。caplen成员是实际捕获的数据量(我们的snaplen变量设为200后,又将其作为pcap_open_live函数的第二个参数),分组捕获机制旨在捕获每个分组的各个首部,而非捕获其中所有数据。len成员是该分组在电缆上出现的完整长度,caplen总是小于len。
由上图,pcap_next函数内部实现中,pcap_read函数依赖于分组捕获设备的类型,如BPF实现调用read、DLPI实现调用getmsg、Linux调用recvfrom。
以下cleanup函数由main函数在程序即将终止时调用,同时也用于键盘输入的中断本程序的信号的信号处理函数:
void cleanup(int signo) {
struct pcap_stat stat;
putc('\n', stdout);
if (verbose) {
// 调用pcap_stats获取分组捕获统计信息
if (pcap_stats(pd, &stat) < 0) {
err_quit("pcap_stats: %s\n", pcap_geterr(pd));
}
// 由过滤器接收的分组总数
printf("%d packets received by filter\n", stat.ps_recv);
// 由内核丢弃的分组总数,丢弃原因为分组到来时没有足够的缓冲区空间存放它
printf("%d packets dropped by kernel\n", stat.ps_drop);
}
exit(0);
}
以下是udp_check函数,它验证IP和UDP首部中的多个字段,我们需要执行这些验证工作,因为由分组捕获设备传递给我们的分组绕过了IP层,这一点不同于原始套接字:
struct udpiphdr *udp_check(char *ptr, int len) {
int hlen;
struct ip *ip;
struct udpiphdr *ui;
// 分组长度必须至少包括IP和UDP首部
if (len < sizeof(struct ip) + sizeof(struct udphdr)) {
err_quit("len = %d", len);
}
/* minimal verification of IP header */
ip = (struct ip *)ptr;
// 验证IP版本
if (ip->ip_v != IPVERSION) {
err_quit("ip_v = %d", ip->ip_v);
}
hlen = ip->ip_hl << 2;
// 验证IP首部长度
if (hlen < sizeof(struct ip)) {
err_quit("ip_hl = %d", ip->ip_hl);
}
if (len < hlen + sizeof(struct udphdr)) {
err_quit("len = %d, hlen = %d", len, hlen);
}
// 验证IP首部校验和
if ((ip->ip_sum = in_cksum((uint16_t *)ip, hlen)) != 0) {
err_quit("ip checksum error");
}
// 如果协议字段表明这是一个UDP数据报,就返回指向IP/UDP组合首部结构的指针
if (ip->ip_p == IPPROTO_UDP) {
ui = (struct udpiphdr *)ip;
return ui;
// 否则就终止程序,因为我们在pcap_setfilter函数中指定了不返回其他类型的分组
} else {
err_quit("not a UDP packet");
}
}
首先使用-0命令行选项运行udpcksum程序,以验证名字服务器对于不带校验和的到达数据报也给出响应,同时还指定-v命令行选项显示详细信息:
之后我们针对一个未开启UDP校验和的本地名字服务器(我们的freebsd4主机)运行udpcksum(不开启UDP校验和的名字服务器越来越少了):
以下是open_output和send_dns_query这两个函数用libnet取代原始套接字实现的版本,libnet替我们关心许多细节问题,包括校验和和IP首部字节序的可移植性。以下是使用libnet的open_output函数:
// libnet使用一个不透明数据类型libnet_t作为调用者和函数库的连接
static libnet_t *l; /* libnet descriptor */
void open_output(void) {
char errbuf[LIBNET_ERRBUF_SIZE];
/* Initialize libnet with an IPv4 raw socket */
// libnet_init函数返回一个libnet_t指针,调用者把它传递给以后的libnet函数以指示所期望的libnet实例
// 从这个意义上来说,它类似于套接字和pcap_t类型的pcap描述符
// 第一个参数为LIBNET_RAW4,会请求libnet_init函数打开一个IPv4原始套接字
// 如果发生错误,libnet_init函数将在它的errbuf参数中返回出错信息,并返回空指针
l = libnet_init(LIBNET_RAW4, NULL, errbuf);
if (l == NULL) {
err_quit("Can't initialize libnet: %s", errbuf);
}
}
以下是使用libnet的send_dns_query函数,可将它与使用原始套接字的send_dns_query和udp_write函数相比较:
void send_dns_query(void) {
char qbuf[24], *ptr;
u_int16_t one;
int packet_size = LIBNET_UDP_H + LIBNET_DNSV4_H + 24;
static libnet_ptag_t ip_tag, udp_tag, dns_tag;
/* build query portion of DNS packet */
// 构造DNS分组的查询问题部分
ptr = qbuf;
memcpy(ptr, "\001a\012root-servers\003net\000", 20);
ptr += 20;
ont = htons(1);
memcpy(ptr, &one, 2); /* query type A */
ptr += 2;
memcpy(ptr, &one, 2); /* query class = 1 (IP addr) */
/* build DNS packet */
// libnet_build_dnsv4函数接受用户参数,用来构造DNS首部
dns_tag = libnet_build_dnsv4(1234, /* identification */
0x0100, /* flags: recursion desired */
1, /* # questions */
0, /* # answer RRs */
0, /* # authority RRs */
0, /* # additional RRs */
qbuf, /* query */
24, /* length of query */
l, dns_tag);
/* build UDP header */
// lib_build_udp函数接受用户参数,用来构造UDP首部
udp_tag = libnet_build_udp(((struct sockaddr_in *)local)->sin_port, /* soure port */
((struct sockaddr_in *)dest)->sin_port, /* dest port */
packet_size, /* length */
0, /* checksum, libnet将自动计算校验和并存入该字段 */
NULL, /* payload */
0, /* payload length */
l, udp_tag);
/* Since we specified the checksum as 0, libnet will automatically */
/* calculate the UDP checksum. Turn it off if the user doesn't want it. */
// 如果用户请求不计算UDP校验和,必须显式禁止UDP校验和计算
if (zerosum) {
if (libnet_toggle_checksum(1, udp_tag, LIBNET_OFF) < 0) {
err_quit("turning off checksums: %s\n", libnet_geterror(1));
}
}
/* build IP header */
// libnet_build_ipv4函数接受用户参数,用来构造IPv4首部
// libnet会自动留意ip_len字段是否为网络字节序,这是通过使用libnet令移植性得以改善的一个例子
ip_tag = libnet_build_ipv4(packet_size + LIBNET_IPV4_H, /* len */
0, /* tos */
0, /* IP ID */
0, /* fragment */
TTL_OUT, /* ttl */
IPPROTO_UDP, /* protocol */
0, /* checksum */
((struct sockaddr_in *)local)->sin_addr.s_addr, /* source */
((struct sockaddr_in *)dest)->sin_addr.s_addr, /* dest */
NULL, /* payload */
0, /* payload length */
l, ip_tag);
// libnet_write函数把组装成的数据报写出到网络
if (libnet_write(l) < 0) {
err_quit("libnet_write: %s\n", libnet_geterror(1));
}
if (verbose) {
printf("sent: %d bytes of data\n", packet_size);
}
}
send_dns_query函数的libnet版本只有67行,而原始套接字版本(send_dns_query和udp_write函数的组合)却有96行,且含有至少两个移植性小问题。
原始套接字使我们有能力读写内核不理解的IP数据报,数据链路层访问则把这个能力进一步扩展成读写任何类型的数据链路帧,而不仅仅是IP数据报。tcpdump也许是直接访问数据链路层的最常用程序。
不同操作系统有不同的数据链路层访问方法,如源自Berkeley的BPF、SVR 4的DLPI、Linux的SOCK_PACKET,如果我们使用公开可得的分组捕获函数库libpcap,我们就可以忽略所有这些区别,编写出可移植的代码。
不同系统上编写原始数据报可能各不相同,公开可得的libnet函数库隐藏了这些差异,所提供的输出接口既可在原始套接字输出,也可在数据链路上直接输出。