到目前为止,本书中所有例子都用数值地址表示主机(如206.6.226.33),用数值端口号来标识服务器(如端口13代表daytime服务器)。但出于某些理由,我们应使用名字而非数值:名字比较容易记住;数值地址可以变动而名字保持不变;随着往IPv6上转移,数值地址变得非常长,手工键入数值地址更易出错。
域名系统(Domain Name System,DNS)主要用于主机名和IP地址之间的映射。主机名既可以是一个简单名字(simple name),如solaris或bsdi,也可以是一个全限定域名(FQDN,Fully Qualified Domain Name),如solaris.unpbook.com。
严格来说,FQDN也称为绝对名字(absolute name),且必须以一个点号来结尾,但用户往往省略结尾的点号,这个点号告知DNS解析器该名字是全限定的,从而不必搜索解析器自己维护的可能域名列表。
DNS中的条目称为资源记录(RR,Resource Record),一些RR类型:
1.A记录把一个主机名映射成一个32位的IPv4地址,例如,以下是unpbook.com域中关于主机freebsd的4个DNS记录,其中第一个是一个A记录:
2.AAAA称为四A(quad A)记录,把一个主机名映射成一个128位的IPv6地址。称其为四A是因为128位地址是32位地址的四倍。
3.PTR指针记录,用于把IP地址映射成主机名。对于IPv4地址,32位地址的4个字节先反转顺序,每个字节都转换成各自的十进制ASCII值(0~255)后,再在后面加上.in-addr.arpa
,结果字符串用于PTR查询。
对于IPv6地址,128位地址中的32个四位组先反转顺序,每个四位组都被转换成相应的16进制ASCII值(0~9,a~f),后面再加上.ip6.arpa
。
例如,上例主机freebsd的两个PRT记录分别为254.32.106.12.in-addr.arpa
和b.6.8.6.7.a.e.f.f.f.0.2.0.0.a.0.1.0.0.0.d.8.f.1.0.8.b.0.e.f.f.3.ip6.arpa
。
早期标准指定在ip6.int域中反向查找IPv6地址,现IPv6的反向查找域已改为ip6.arpa,以与IPv4保持一致。存在一个过渡期,期间两者都可以使用。
4.MX把一个主机指定为给定主机的邮件交换器,上例中主机freebsd有2个MX记录,第一个优先级为5,第二个优先级为10,值越小优先级越高。
5.CNAME表示规范名字(Canonical Name),它的常见用法是为常用服务(如ftp和www)指派CNAME记录,如果人们用的是服务名而非真实的主机名,那么相应的服务挪到另一个主机时用户也不用知道,例如我们的linux主机有以下2个CNAME记录:
目前处于IPv6部署的极早期,系统管理员们会给同时支持IPv4和IPv6的主机使用什么样的命名约定尚不清楚。一种可能的约定是:把A记录和AAAA记录都置于主机名之下;再创建一个以主机名-4
为主机名,含有A记录的RR;再创建一个以主机名-6
结尾,含有AAAA记录的RR;再创建一个以主机名-611
结尾,含有指向链路本地地址的AAAA记录(为了便于调试)。以下aix主机有这些记录:
每个组织机构往往运行一个或多个名字服务器,他们通常是所谓的BIND(Berkeley Internet Name Domain)程序。客户和服务器通过调用称为解析器的函数库中的函数接触DNS服务器。常见的解析器函数是gethostbyname和gethostbyaddr函数,前者把主机名映射为IPv4地址,后者执行相反的映射。
解析器代码通常包含在一个系统函数库中,在构造应用程序时被link-editing(即把多个目标文件链接成可执行文件的过程)到程序中。还有些系统提供一个由全体应用进程共享的集中式解析器守护进程,并提供向这个守护进程执行RPC的系统函数库代码。
解析器代码通过读取其系统相关配置文件确定本组织机构的名字服务器们(出于可靠可冗余的目的,必须要设置多个名字服务器)所在位置。文件/etc/resolv.conf通常包含本地名字服务器主机的IP地址。
既然名字比地址好记易配,如果能在/etc/resolv.conf文件中也使用名字服务器主机的名字该多好,但这样做名字服务器主机自身的名字到地址转换由谁执行呢。
解析器使用UDP向本地名字服务器发出查询,如果本地名字服务器不知道答案,它通常会使用UDP在整个因特网上查询其他名字服务器,如果答案太长,超出UDP消息的最大大小,本地名字服务器和解析器会自动切换到TCP。
不使用DNS也可能获取名字和地址信息,常用的替代方法有静态主机文件(/etc/hosts)、网络信息系统(NIS,Network Information System)、轻权目录访问协议(LDAP,Lightweight Directory Access Protocol)。但系统管理员如何配置主机使用哪种类型的名字服务是实现相关的,Solaris 2.x、HP-UX 10及之后版本、FreeBSD 5.x及之后版本使用文件/etc/nsswitch.conf;AIX使用文件/etc/netsvc.conf;BIND 9.2.2提供了自己的名为信息检索服务(IRS,Information Retrival Sevice)的版本,使用文件/etc/irs.conf。如果使用名字服务器,那么所有这些系统使用文件/etc/resolv.conf指定名字服务器的地址。但这些差异对应用开发人员是透明的,我们只需调用诸如gethostbyname和gethostbyaddr这样的解析器函数。
大多应用应处理名字而非地址,尤其是往IPv6转移时,因为IPv6地址(十六进制数串)比IPv4点分十进制数串长得多。
gethostbyname函数返回一个指向hostent结构的指针,该结构含有所查主机的所有IPv4地址。此函数的局限是只能返回IPv4地址,而后面要说的getaddrinfo函数能同时处理IPv4和IPv6地址。POSIX规范预警可能会在将来版本中撤销gethostbyname函数。
gethostname函数不大可能真正消失,除非整个因特网改为使用IPv6,那可能在遥遥无期的将来,从POSIX规范中撤销该函数意在声明新的程序不该再使用它,鼓励在新程序中改用getaddrinfo函数。
hostent结构:
gethostbyname函数执行的是对A记录的查询,它只能返回IPv4地址。
上图中,假设所查的主机名有2个别名和3个IPv4地址,所查询主机的正式主机名和所有别名都是以空字符结尾的C字符串。
h_name字段称为所查询主机的规范(canonical)名字。以上面我们的CNAME例子为例,主机ftp.unpbook.com的规范名字是linux.unpbook.com。如果我们在主机aix上以一个非限定主机名(如solaris)调用gethostbyname,则作为规范名返回的是它的FQDN(即solaris.unpbook.com)。
有些版本的gethostbyname函数实现允许hostname参数是一个点分十进制数串。POSIX规范允许但不强求如此处理hostname函数,因此考虑可移植性的程序不能依赖这个特性。
gethostbyname函数在发生错误时,不设置errno变量,而是将全局变量h_errno设为以下常值之一(这些常值定义在头文件netdb.h中):
1.HOST_NOT_FOUND
2.TRY_AGAIN
3.NO_RECOVERY
4.NO_DATA(等同于NO_ADDRESS)
NO_DATA错误表示指定的名字有效,但它没有A记录,只有MX记录的主机就是这样的例子。
多数解析器提供名为hstrerror的函数,它以某个h_errno值作为唯一参数,返回一个const char *指针,指向响应错误的说明。
调用gethostbyname函数,并显示返回的所有信息(代码11-3):
#include "unp.h"
int main(int argc, char **argv) {
char *ptr, **pptr;
char str[INET_ADDRSTRLEN];
struct hostent *hptr;
while (--argc > 0) {
ptr = *++argv;
if ((hptr = gethostbyname(ptr)) == NULL) {
err_msg("gethostbyname error for host: %s: %s", ptr, hstrerror(h_errno));
continue;
}
printf("official hostname: %s\n", hptr->h_name);
for (pptr = hptr->h_aliases; *pptr != NULL; ++pptr) {
printf("\talias: %s\n", *pptr);
}
switch (hptr->h_addrtype) {
case AF_INET:
pptr = hptr->h_addr_list;
for (; *pptr != NULL; ++pptr) {
printf("\taddress: %s\n", Inet_ntop(hptr->h_addrtype, *pptr, str, sizeof(str)));
}
break;
default:
err_ret("unknown address type");
break;
}
}
exit(0);
}
以主机aix的名字作为参数运行该程序,该主机只有一个IPv4地址:
正式主机名(official host)就是FQDN。即使主机aix有IPv6地址,返回的也只有IPv4地址。
有多个IPv4地址的Web服务器主机的输出:
下例中有一个CNAME记录:
为了查看由hstrerror函数返回的错误信息串,我们先指定一个不存在的主机名,再指定一个仅有MX记录的名字:
gethostbyaddr函数试图由一个二进制IP地址找到相应主机名,与gethostbyname函数的行为刚好相反:
gethostbyaddr函数返回一个指向hostent结构的指针,对于该结构,我们感兴趣的通常是存放规范主机名的h_name字段。
addr参数实际上不是char *类型,而是一个指向存放IPv4地址的某个in_addr结构的指针。len参数是这个结构的大小,对于IPv4地址为4。family参数为AF_INET。
gethostbyaddr函数在in_addr.arpa域中向一个名字服务器查询PTR记录。
像主机一样,服务器也通常靠名字来认知,如果我们在代码中通过其名字而非其端口号来指代一个服务,且从名字到端口号的映射保存在一个文件中(通常是/etc/services),那么即使端口号发生变动,我们需修改的仅仅是/etc/services文件中的某一行,而不用重新编译应用程序。getservbyname函数根据给定名字查找相应服务。
赋予各个服务的端口号规范列表由IANA维护,/etc/services文件通常包含由IANA维护的规范赋值列表的某个子集。
getservbyname函数返回的非空指针指向如下的servent结构:
servname参数必须指定,如果同时指定了协议(protoname参数为非空指针),那么指定服务必须有匹配的协议。有些因特网服务既用TCP也用UDP提供(如DNS),有些因特网服务则仅支持单个协议(如FTP要求使用TCP)。如果protoname参数未指定而servname参数指定的服务支持多个协议,那么返回哪个端口号取决于实现,通常这是无关紧要的,因为支持多个协议的服务往往使用相同的TCP和UDP端口号,但这点并没有保证。
servent结构中我们关心的主要字段是端口号,端口号是以网络字节序返回的,把它存到套接字地址结构时不用再调用htons。
getservbyname函数的典型调用:
以下是/etc/services文件中的典型行:
getservbyport函数根据给定端口号和可选协议查找相应服务:
port参数的值必须是网络字节序。getservbyport函数的典型调用如下:
有些端口号在TCP上用于一种服务,在UDP上用于另一种服务:
端口514在TCP上由rsh命令使用,在UDP上由syslog守护进程使用。512~514范围内的端口都有这个特性。
把TCP的daytime客户程序改为使用gethostbyname和getservbyname函数,并改用2个命令行参数:主机名和服务名,以下是改动后的程序,它还会尝试连接到多宿服务器主机的每个IP地址,直到有一个连接成功或所有地址尝试完毕,以下是程序11-4:
#include "unp.h"
int main(int argc, char **argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
struct sockaddr_in servaddr;
struct in_addr **pptr;
struct in_addr *inetaddrp[2];
struct in_addr inetaddr;
struct hostent *hp;
struct servent *sp;
if (argc != 3) {
err_quit("usage: daytimetcp " );
}
if ((hp = gethostbyname(argv[1])) == NULL) {
// 如果gethostbyname函数失效,则直接尝试使用inet_aton函数,确定该参数是否已经是ASCII格式地址
if (inet_aton(argv[1], &inetaddr) == 0) {
err_quit("hostname error for %s: %s", argv[1], hstrerror(h_errno));
} else {
inetaddrp[0] = &inetaddr;
inetaddrp[1] = NULL;
pptr = inetaddrp;
}
} else {
pptr = (struct in_addr **)hp->h_addr_list;
}
if ((sp = getservbyname(argv[2], "tcp")) == NULL) {
err_quit("getservbyname error for %s", argv[2]);
}
// 该循环为服务器主机的每个地址执行一次
for (; *pptr != NULL; ++pptr) {
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
// 我们可以把bzero调用和后面两个赋值语句置于循环体外提高执行效率,但这样更易读
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = sp->s_port;
memcpy(&servaddr.sin_addr, *pptr, sizeof(struct in_addr));
printf("trying %s\n", Sock_ntop((SA *)&servaddr, sizeof(servaddr)));
if (connect(sockfd, (SA *)&servaddr, sizeof(servaddr)) == 0) {
break; /* success */
}
// err_ret函数打印消息并记入系统日志,然后返回
err_ret("connect error");
// connect调用失败的套接字描述符必须关闭,不能再用
close(sockfd);
}
if (*pptr == NULL) {
err_quit("unable to connect");
}
while ((n = Read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
exit(0);
}
运行以上客户程序:
对一个没有运行标准daytime服务器的多宿系统运行以上程序:
11-4程序中,如果把主机名换成点分十进制数串,只有某些较新版本的BIND(一套与域名系统DNS交互的软件套件)才支持,但POSIX没有规定如何处理这种情况,可移植程序中不能依赖它。我们可以修改以上程序为支持点分十进制数串形式的地址和数字形式的服务:
#include "unp.h"
int main(int argc, char **argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
struct sockaddr_in servaddr;
struct in_addr **pptr, *addrs[2];
struct hostent *hp;
struct servent *sp;
if (argc != 3) {
err_quit("usage: daytimetcpcli2 " );
}
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
if (inet_pton(AF_INET, argv[1], &servaddr.sin_addr) == 1) {
addrs[0] = &servaddr.sin_addr;
addrs[1] = NULL;
} else if ((hp = gethostbyname(argv[1])) != NULL) {
pptr = (struct in_addr **)hp->h_addr_list;
} else {
err_quit("hostname error for %s: %s", argv[1], hstrerror(h_errno));
}
if ((n = atoi(argv[2])) > 0) {
servaddr.sin_port = htons(n);
} else if ((sp = getservbyname(argv[2], "tcp")) != NULL) {
servaddr.sin_port = sp->s_port;
} else {
err_quit("getservbyname error for %s", argv[2]);
}
for (; *pptr != NULL; ++pptr) {
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
// 此处将memcpy函数改为memmove函数,两者功能相同,但后者能正确处理源、目的内存重叠的情形
// 如果主机名字符串是一个点分十进制IP地址,则此时调用memmove的源和目的地址是相同的
memmove(&servaddr.sin_addr, *pptr, sizeof(struct in_addr));
printf("trying %s\n", Sock_ntop((SA *)&servaddr, sizeof(servaddr)));
if (connect(sockfd, (SA *)&servaddr, sizeof(servaddr)) == 0) {
break; /* success */
}
err_ret("connect error");
close(sockfd);
}
if (*pptr == NULL) {
err_quit("unable to connect");
}
while ((n = Read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
exit(0);
}
以上代码中,我们先调用了inet_pton检查是否是点分十进制数串,然后再调用gethostbyname,这是因为inet_pton函数会在本地判定主机名字符串是否是一个有效的点分十进制地址,只有当这种测试失效时我们才调用gethostbyname,后者往往涉及某些网络资源,因此需要花一些时间。
11-4程序只支持IPv4,以下修改使得它同时支持IPv4和IPv6,有些gethostbyname函数的实现支持IPv6,但标准只说支持IPv4:
#include "unp.h"
int main(int argc, char **argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
struct sockaddr_in servaddr;
struct sockaddr_in6 servaddr6;
struct sockaddr *sa;
socklen_t salen;
struct in_addr **pptr;
struct hostent *hp;
struct servent *sp;
if (argc != 3) {
err_quit("usage: daytimetcpcli3 " );
}
if ((hp = gethostbyname(argv[1])) == NULL) {
err_quit("hostname error for %s: %s", argv[1], hstrerror(h_errno));
}
if ((sp = getservbyname(argv[2], "tcp")) == NULL) {
err_quit("getservbyname error for %s", argv[2]);
}
pptr = (struct in_addr **)hp->h_addr_list;
for (; *pptr != NULL; ++pptr) {
sockfd = Socket(hp->h_addrtype, SOCK_STREAM, 0);
if (hp->h_addrtype == AF_INET) {
sa = (SA *)&servaddr;
salen = sizeof(servaddr);
} else if (hp->h_addrtype == AF_INET6) {
sa = (SA *)&servaddr6;
salen = sizeof(servaddr6);
} else {
err_quit("unknown addrtype %d", hp->h_addrtype);
}
bzero(sa, salen);
sa->sa_family = hp->h_addrtype;
sock_set_port(sa, salen, sp->s_port);
sock_set_addr(sa, salen, *pptr);
printf("trying %s\n", Sock_ntop(sa, salen));
if (connect(sockfd, sa, salen) == 0) {
break; /* success */
}
err_ret("connect error");
close(sockfd);
}
if (*pptr == NULL) {
err_quit("unable to connect");
}
while ((n = Read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
exit(0);
}
以上程序使用gethostbyname函数返回的h_addrtype字段判断地址类型,并使用我们自编写的sock_set_port和sock_set_addr函数用合适的方式(IPv4地址用IPv4的方式,IPv6同理)设置套接字地址结构中的端口和地址两个字段。
以上程序虽然能工作,但有两个局限:一是我们要处理IPv4和IPv6的所有差异,在查看h_addrtype字段后再设置合适的sa和salen,更好的方式是由某个库函数完成整个套接字地址结构的填写(如getaddrinfo函数);二是以上程序只在支持IPv6的主机上编译,如果要在仅支持IPv4的主机上编译,就要加很多#ifdef,从而使代码变得很复杂。
gethostbyname和gethostbyaddr函数只支持IPv4。getaddrinfo函数能处理名字到地址以及服务到端口的转换,且支持IPv6,它返回的是一个addrinfo结构的列表,这些addrinfo结构中的sockaddr结构可由套接字函数直接使用,这样getaddrinfo函数就把协议相关性完全隐藏在此库函数内部,该函数在POSIX中定义。
getaddrinfo函数通过result指针参数返回一个指向addrinfo结构链表的指针,addrinfo结构定义在头文件netdb.h中:
hostname参数是一个主机名或地址串(IPv4的点分十进制串或IPv6的十六进制串)。service参数是一个服务名或十进制端口号数串。
hints参数可以是一个空指针,也可以是一个指向addrinfo结构的指针,调用者在该结构中填入期望返回的信息类型,例如,如果指定的服务既支持TCP也支持UDP(如某DNS服务器的domain服务),那么调用者可以把hints结构中的ai_socktype成员设为SOCK_DGRAM,使得返回的仅仅适用于数据报套接字的信息。
hints参数指针指向的结构中,调用者可设置的成员:
1.ai_flags:0个或多个被OR在一起的AI_XXX值。可用值及含义如下:
(1)AI_PASSIVE:套接字将用于被动打开。
(2)AI_CANONNAME:告知getaddrinfo函数返回主机的规范名字。
(3)AI_NUMERICHOST:限定hostname参数必须是一个地址串而不能是名字。
(4)AI_NUMERICSERV:限定service参数必须是一个十进制端口号而不能是服务名。
(5)AI_V4MAPPED:如果同时指定ai_family成员为AF_INET6,则如果没有可用的AAAA记录,就返回A记录对应的IPv4地址映射的IPv6地址(即将IPv4地址放在IPv6地址的低32位,高96位用0:0:0:0:0:FFFF填充,这样可以用通用格式保存IPv4和IPv6地址)。
(6)AI_ALL:如果同时指定AI_V4MAPPED标志,那么除返回与AAAA记录对应的IPv6地址外,还返回与A记录对应的IPv4映射的IPv6地址。
(7)AI_ADDRCONFIG:按所在主机的配置选择返回的地址类型,只有某非环回接口配置了给定版本的IP地址时,才会查找该给定版本的IP地址。
2.ai_family:某个AF_XXX值。
3.ai_socktype:某个SOCK_XXX值。
4.ai_protocol。
如果hints参数是一个空指针,getaddrinfo函数假设ai_flag、ai_socktype、ai_protocol的值为0,ai_family的值为AF_UNSPEC。
如果getaddrinfo函数返回成功(0),那么由result函数指向的变量已被填入一个指针,该指针指向由其中的ai_next成员串接起来的addrinfo结构链表。以下情形会返回多个addrinfo结构:
1.与hostname参数关联的地址有多个,则所请求地址族(通过hints结构的ai_family成员设置)的每个地址都返回一个对应结构。
2.service参数指定的服务支持多个套接字类型,则每个套接字类型都可能返回一个对应结构,具体取决于ai_socktype成员。多数getaddrinfo函数实现只返回ai_socktype成员请求的套接字类型的信息,如果没有这个成员,就返回一个错误。
一个例子,如果没有任何hints信息,请求查找有两个IP地址的某主机上的domain服务,将返回4个addrinfo结构:
1.第一个IP+SOCK_STREAM套接字类型。
2.第一个IP+SOCK_DGRAM套接字类型。
3.第二个IP+SOCK_STREAM套接字类型。
4.第二个IP+SOCK_DGRAM套接字类型。
上例返回的信息如下图,addrinfo结构的先后顺序没有保证:
上图是以下代码的执行返回:
struct addrinfo hints, *res;
bzero(&hints, sizeof(hints));
hints.ai_flags = AI_CANONNAME;
hints.ai_family = AF_INET;
getaddrinfo("freebsd4", "domain", &hints, &res);
除res变量外的内容都是由getaddrinfo函数动态分配的空间(如来自malloc调用)。
上图中,端口53用于domain服务,这个端口号在套接字地址结构中按网络字节序存放。getaddrinfo函数返回的ai_protocol值为IPPROTO_TCP或IPPROTO_UDP。如果ai_family和ai_socktype组合能完全指定TCP或UDP协议,则返回的ai_protocol为0也可接受,即如果这个组合足以指定TCP(例如,如果系统实现了SCTP,则不足),则getaddrinfo函数返回的ai_protocol值为0也是可以接受的;如果系统不实现IP栈中的任何SOCK_DGRAM协议(截至本文撰写时,尚未标准化任何SOCK_DGRAM协议,但IETF正在开发两个这类协议),则getaddrinfo函数返回的ai_protocol为0也是可以接受的。最安全的做法是getaddrinfo函数始终明确返回特定协议。
尽管没有保证,但一个getaddrinfo函数的应该按DNS返回顺序返回各个IP地址。有些解析器允许系统管理员在/etc/resolv.conf文件中指定地址的排序顺序。IPv6在RFC中指定了地址选择规则(这些规则确定了源地址的选择顺序,以及在选择源地址时考虑的因素),可能会影响到getaddrinfo函数返回地址的顺序。
addrinfo结构中的信息可直接用于socket调用,以及之后的connect、sendto函数(客户端)和bind函数(服务器)。socket函数的参数就是addrinfo结构中的ai_family、ai_socktype、ai_addr成员;connect或bind函数的第二个和第三个参数就是该结构中的ai_addr(一个指向适当类型套接字地址结构的指针,地址结构的内容由getaddrinfo函数填写)和ai_addrlen(这个套接字地址结构的大小)成员。
如果hints结构中设置了AI_CANONNAME,getaddrinfo函数返回的第一个addrinfo结构的ai_canonname成员指向所查找主机的规范名字。规范名字通常是FQDN。telnet之类的程序使用此标志显示所连接到主机的规范名字,这样即使用户给定的是一个简单名字或别名,也能知道真正查找的名字。
如上图,在不考虑SCTP的前提下,只有在未提供ai_socktype暗示信息且该服务支持多个运输层协议时才可能为每个IP地址返回多个addrinfo结构。
以下是getaddrinfo函数常见的输入:
1.指定hostname和service参数。TCP客户可在一个循环中针对每个返回的IP地址,逐一调用socket和connect,直到有一个连接成功,或所有地址尝试完毕。UDP客户可将由getaddrinfo函数填入的套接字地址结构用于调用sendto和connect,如果客户能判定第一个地址看起来不工作(如已连接UDP套接字上收到出错消息、未连接套接字上接收消息超时),则可以尝试其余地址。
如果客户只处理一种类型的套接字(如Telnet和FTP客户只处理TCP,TFTP客户只处理UDP),则应设置hints参数结构的ai_socktype成员。
2.典型的服务器进程只指定service参数,不指定hostname参数,同时在hints结构中指定AI_PASSIVE标志。返回的套接字地址结构中应该只有一个值为INADDR_ANY或INADDR6_ANY_INIT的IP地址。TCP服务器随后调用socket、bind、listen。如果服务器想malloc另一个套接字地址结构以从accept函数获取客户地址,则返回的ai_addrlen的值给出了这个套接字地址结构的大小。
如果服务器只处理一种类型的套接字,则应设置hints参数结构中的ai_socktype成员,可避免返回不需要的结构。
3.之前的例子中,TCP服务器仅创建一个监听套接字,UDP服务器也只创建一个数据报套接字,但服务器可能会使用select或poll函数让服务器进程处理多个套接字,此时,服务器应遍历getaddrinfo函数返回的所有addrinfo结构链表,并为每个结构创建一个套接字,再使用select或poll函数。
但这样做的技术问题在于,getaddrinfo函数返回多个结构的原因之一是有服务可同时由IPv4和IPv6处理,这两个版本的IP协议并非完全独立,如果我们为某给定端口创建了一个IPv6监听套接字,则没有必要再为同一个端口再创建一个IPv4套接字,因为来自IPv4客户的连接将由协议栈和IPv6监听套接字处理(假设IPV6_V6ONLY套接字选项未设置且系统配置正确(如Linux中,要将net.ipv6.bindv6only设为0))。
getaddrinfo函数可方便我们编写协议无关的代码,它的反义函数为getnameinfo,把套接字地址结构转换成主机名和服务名。
gai_strerror函数以getaddrinfo函数返回的非0错误值为参数,返回一个指向对应的出错信息串的指针:
getaddrinfo函数返回的所有空间都是动态获取的,包括addrinfo结构、ai_addr结构、ai_canonname字符串,这些空间通过freeaddrinfo函数还给系统:
ai参数应指向由getaddrinfo函数返回的第一个addrinfo结构,这个链表中的所有结构及它们指向的动态存储空间都被释放掉。
假设我们调用getaddrinfo,遍历返回的addrinfo结构链表后找到了所需结构,如果我们为保存所需结构而仅仅复制了这个addrinfo结构,然后调用freeaddrinfo,就出现了错误,因为addrinfo结构本身指向动态分配的内存空间,因此我们复制的结构指向的内存已被还给系统。
只复制这个addrinfo结构而不复制由它指向的其他结构称为浅复制,既复制这个addrinfo结构又复制由它指向的所有其他结构称为深复制。
POSIX规范定义了getaddrinfo函数以及该函数为IPv4和IPv6返回的信息:
1.getaddrinfo函数进行两种处理:调用者想要的套接字地址结构、对DNS或其他数据库进行的记录查询。
2.由调用者在hints结构中提供的地址族来指定调用者期待返回的套接字地址结构的类型,如果调用者指定的是AF_INET,getaddrinfo函数就不能返回sockaddr_in6结构;如果调用者指定AF_INET6,getaddrinfo函数就不能返回sockaddr_in结构。
3.如果调用者指定的地址族为AF_UNSPEC,则getaddrinfo函数返回适用于指定主机名和服务名的任意协议族的地址。如果某个主机既有AAAA记录,又有A记录,则AAAA记录将作为sockaddr_in6结构返回,A记录将作为sockaddr_in结构返回。但不会为IPv4地址返回sockaddr_in6结构(IPv4映射的IPv6地址),因为这么做没有提供额外信息,这些地址已经在sockaddr_in结构中返回过了。
4.如果设置了AI_PASSIVE标志但没有指定主机名,则IPv6通配地址(IN6ADDR_ANY_INIT或0::0)会作为sockaddr_in6结构返回,IPv4通配地址(INADDR_ANY或0.0.0.0)会作为sockaddr_in结构返回。且会先返回IPv6通配地址,因为双栈主机上IPv6服务器能同时处理IPv6客户和IPv4客户。
IPv6通配地址的缩写规则:
(1)在每个四位组中,删除开头和连续的0(0001 -> 1,0000 -> 0,0092 -> 92)。
(2)如果有两个或多个连续的全0四位组,则用双冒号(::)替换它,但在单个地址中只能使用一次双冒号。
因此0::0表示全0的IPv6地址,也可直接用::表示。
5.hints结构中的ai_family成员指定的地址族和ai_flags成员指定的AI_V4MAPPED、AI_ALL标志决定了在DNS中查找的资源记录类型(A、AAAA),也决定了返回地址的类型(IPv4、IPv6、IPv4映射的IPv6)。
6.主机名参数也可以是IPv6的十六进制数串或IPv4的点分十进制数串,如果指定AF_INET,就不会接受IPv6的十六进制数串;如果指定AF_INET6,就不会接受IPv4的点分十进制数串;如果指定的是AF_UNSPEC,则两种数串都能接受,返回的是数串相应类型的套接字地址结构。
可能有人会争论说,如果指定了AF_INET6,则点分十进制数串会作为IPv4映射的IPv6地址在sockaddr_in6结构中返回,这是正确的,但另一个方法也可获得同样的结果,就是在点分十进制数串前加上0::ffff:,并指定AF_INET6。
上图中,结果列是在给定前三列后,该函数返回给调用者的结果,行为列说明函数如何获取这些结果。
使用一个测试程序展示getaddrinfo函数,该程序允许我们输入:主机名、服务名、地址族、套接字类型、AI_CANONNAME、AI_PASSIVE。该程序输出getaddrinfo函数返回的addrinfo结构中的信息,以及用该结构中信息如何调用socket,以下是与图11-5同样的例子:
如上图,-f指定地址族,-c表示返回规范主机名,-h指定主机名,-s指定服务名。
通常的客户情景会查询地址族、套接字类型(-t选项)、主机名、服务名,以下是对一个有3个IPv4地址的多宿主机查询这些:
指定主机为aix,它有1个AAAA记录和1个A记录,不指定地址族,但指定服务名为ftp,此服务仅在TCP上提供:
最后我们指定AI_PASSIVE标志(-p选项),但不指定地址族,也不指定主机名(即使用通配地址),指定端口号为8888,指定套接字类型为SOCK_STREAM:
上图返回了2个结构(分别是IPv4和IPv6的通配地址,且IPv6地址结构早于IPv4地址结构返回,因为双栈主机上IPv6既能与IPv6对端通信,也能与IPv4对端通信),因为我们是在一个同时支持IPv6和IPv4的主机上运行此例,且没有指定地址族。
可以自定义一个getaddrinfo函数的接口函数,使得我们每次调用getaddrinfo时不用再分配并填写hints结构,而是将我们感兴趣的两个字段(地址族和套接字类型)作为该函数的参数:
以下是该函数的源码:
#include "unp.h"
struct addrinfo *host_serv(const char *host, const char *serv, int family, int socktype) {
int n;
struct addrinfo hints, *res;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_flags = AI_CANONNAME; /* always return canonical name */
hints.ai_family = family; /* AF_UNSPEC, AF_INET, AF_INET6, etc. */
hints.ai_socktype = socktype; /* 0, SOCK_STREAM, SOCK_DGRAM, etc. */
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0) {
return NULL;
}
return res; /* return pointer to first on linked list */
}
编写一个使用getaddrinfo函数的函数,用于创建一个TCP套接字并连接到一个服务器:
以下是该函数的源码:
#include "unp.h"
int tcp_connect(const char *host, const char *serv) {
int sockfd, n;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0) {
err_quit("tcp_connect error for %s, %s: %s", host, serv, gai_strerror(n));
}
ressave = res;
do {
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
// socket函数失败不是致命错误,如果返回地址中有IPv6地址而主机内核不支持IPv6,这种失败就可能发生
if (sockfd < 0) {
continue; /* ignore this one */
}
if (connect(sockfd, res->ai_addr, res->ai_addrlen) == 0) {
break; /* success */
}
Close(sockfd); /* ignore this one */
} while ((res = res->ai_next) != NULL);
if (res == NULL) { /* errno set from final connect() */
err_sys("tcp_connect error for %s, %s", host, serv);
}
freeaddrinfo(ressave);
return sockfd;
}
以上函数和其他我们自己编写的getaddrinfo函数的接口函数都没有返回getaddrinfo函数返回的错误码(某EAI_xxx常值),这意味着它们的包裹函数什么都没做:
int Tcp_connect(const char *host, const char *serv) {
return tcp_connect(host, serv);
}
尽管如此,为保持全书一致性,我们照样使用包裹函数。
以上问题在于tcp_connect函数返回的描述符是非负的,但我们不清楚EAI_xxx是正的还是负的,如果这些值也是正的,那么我们可以在getaddrinfo函数失败时返回这些值的负值,但我们还需要某个负值还表明所有结构都尝试完毕,但无一成功。
把获取时间的客户程序改写成使用tcp_connect函数:
#include "unp.h"
int main(int argc, char **argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
socklen_t len;
struct sockaddr_storage ss;
if (argc != 3) {
err_quit("usage: daytimetcpconnect " );
}
sockfd = Tcp_connect(argv[1], argv[2]);
len = sizeof(ss);
Getpeername(sockfd, (SA *)&ss, &len);
printf("connected to %s\n", Sock_ntop_host ((SA *)&ss, len));
while ((n = Read(sockfd, recvline, MAXLINE)) > 0) {
recvline [n] = 0; /* null terminate */
Fputs (recvline, stdout);
}
exit(0);
}
tcp_connect函数只返回了连接后的套接字,而我们不知道具体套接字地址结构的大小,因此我们调用getpeername函数时使用了sockaddr_storage类型的套接字地址结构,它足以存放系统支持的任何套接字地址类型,又能满足它们的对齐限制。
以上版本的客户程序同时支持IPv4和IPv6。
运行以上程序,指定一个只支持IPv4的主机名:
再次运行该程序,指定一个同时支持IPv4和IPv6的主机名:
上图使用IPv6的原因在于,该主机同时有1个AAAA记录和一个A记录,且tcp_connect函数把地址族设为AF_UNSPEC,getaddrinfo函数会先搜索AAAA记录,再搜索A记录,connect顺序靠前的IPv6地址成功后,就不再尝试顺序靠后的IPv4地址。
下例中,我们指定带-4后缀的主机名强制使用IPv4地址,这是一个约定,这样的主机名只有A记录:
recv函数的MSG_PEEK标志可读出接收缓冲区中的数据,但并不从接收缓冲区中移除读到的数据;以FIONREAD参数调用ioctl函数可获取套接字缓冲区中的字节数。修改以上程序,先指定MSG_PEEK标志调用recv,然后以FIONREAD为参数调用ioctl,最后再调用read真正读入数据,以下是修改后的循环:
for (; ; ) {
if ((n = Recv(sockfd, recvline, MAXLINE, MSG_PEEK)) == 0) {
break;
}
Ioctl(sockfd, FIONREAD, npend);
printf("%d bytes from PEEK, %d bytes pending\n", n, npend);
n = Read(sockfd, recvline, MAXLINE);
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
下一个我们自己编写的函数会执行TCP服务器的通常步骤:创建一个TCP套接字,给它绑定服务器的众所周知端口,并允许接受外来请求:
以下是它的源码:
#include "unp.h"
int tcp_listen(const char *host, const char *serv, socklen_t *addrlenp) {
int listenfd, n;
const int on = 1;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_flags = AI_PASSIVE; // 本函数供服务器使用
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
// 如果不指定主机名,则返回的地址为通配地址
// 对于AI_PASSIVE和AI_UNSPEC暗示,IPv4和IPv6的地址都会返回(假定运行在双栈主机上)
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0) {
err_quit("tcp_listen error for %s, %s: %s", host, serv, gai_strerror(n));
}
ressave = res;
do {
listenfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (listenfd < 0) {
continue; /* error, try next one */
}
Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
if (bind(listenfd, res->ai_addr, res->ai_addrlen) == 0) {
break; /* success */
}
Close(listenfd); /* bind error, close and try next one */
} while ((res = res->ai_next) != NULL);
// 如果每个地址结构的socket调用或bind调用都失败,显示一个出错消息并终止
if (res == NULL) { /* errno from final socket() or bind() */
err_sys("tcp_listen error for %s, %s", host, serv);
}
Listen(listenfd, LISTENQ);
// 返回协议地址大小,此值允许调用者在调用accept获取客户协议地址时分配一个套接字地址结构的内存空间
if (addrlenp) {
*addrlenp = res->ai_addrlen; /* return sizeof protocol address */
}
freeaddrinfo(ressave);
return listenfd;
}
在tcp_listen函数中,调用者必须传一个addrlenp参数来获取协议地址的大小,如果调用者传一个空指针,调用者如果想知道协议地址的大小,可以分配一个比任何套接字地址结构都要大的大缓冲区并调用getsockname,getsockname函数的第三个参数是值-结果参数,它会返回协议地址真正的大小。
使用tcp_listen函数改写时间获取服务器:
#include "unp.h"
#include
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t len;
char buff[MAXLINE];
time_t ticks;
struct sockaddr_storage cliaddr;
// 我们用一个命令行参数来指定服务名或端口号,便于测试,因为daytime服务器捆绑端口13,需要超级用户特权
if (argc != 2) {
err_quit("usage:daytimetcpsrv1 " );
}
// 我们将使用sockaddr_storage,因此第3个参数填空指针,我们不关心当前地址族使用多大的地址结构
listenfd = Tcp_listen(NULL, argv[1], NULL);
for (; ; ) {
len = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *)&cliaddr, &len);
// sock_ntop函数是自定义函数,它输出客户的地址,无论是IPv4还是IPv6地址,都会输出IP地址和端口号
printf("connection from %s\n", Sock_ntop((SA *)&cliaddr, len));
// 此处我们也可输出客户的主机名,我们可用getnameinfo函数尝试获取客户主机的主机名
// 但这会涉及DNS的PTR记录查询,需要花一段时间,特别是在查询失败时
// 且TCPv3指出,与web服务器建立连接的所有客户主机中,25%没有PTR记录
// 因为我们不想让服务器(特别是迭代服务器)为PTR查询等待数秒,我们就不显示客户的主机名了,只显示客户的IP和端口号
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));
Close(connfd);
}
}
上例中,我们可以改用getnameinfo函数替代sock_ntop函数,具体做法可以是先分配存放主机名和服务名的数组:
char host[NI_MAXHOST], serv[NI_MAXSERV];
然后在accept函数返回后改为调用getnameinfo以取代sock_ntop函数:
if (getnameinfo(cliaddr, len, host, NI_MAXHOST, serv, NI_MAXSERV, NI_NUMERICHOST | NI_NUMERICSERV) == 0) {
printf("connection from %s.%s", host, serv);
}
由于这是服务器程序,我们指定NI_NUMERICHOST和NI_NUMERICSERV标志以避免查询DNS和查找/etc/services文件。
NI_MAXHOST和NI_MAXSERV常量定义在netdb.h头文件中,分别代表主机名和服务名的最大长度,用于动态分配足够的缓冲区来存储主机名和服务名。
上例中,我们调用tcp_listen时第一个参数是NULL,且我们指定的地址族为AF_UNSPEC,这在双栈主机上返回的第一个套接字地址结构将是IPv6的,但我们可能希望服务器仅处理IPv4。
对于客户主机没有这种问题,因为客户需要输入一个IP地址或主机名,因此客户可以输入一个与特定类型的IP地址关联的主机名(-4或-6后缀的主机名),要么直接输入IPv4的点分十进制串或IPv6的十六进制数串以强制使用IPv4或IPv6。
有一个技巧可以让我们强制服务器使用某个给定协议,我们可以让用户输入一个IP地址,getaddrinfo函数获取到的每个地址结构,对其套接字地址字段调用inet_pton,下图指明调用是否会成功:
因此我们可以把服务器程序改为接收一个可选参数,如果键入:
% server
在双栈主机上就默认使用IPv6,如果键入:
% server 0.0.0.0
则显式指定使用IPv4,键入:
% server 0::0
则显式指定IPv6。
以下是获取时间服务器的最终版本:
#include "unp.h"
#include
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t len, addrlen;
char buff[MAXLINE];
time_t ticks;
struct sockaddr_storage cliaddr;
if (argc == 2) {
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
} else if (argc == 3) {
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
} else {
err_quit("usage: daytimetcpser2 [] | " );
}
for (; ; ) {
len = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *)&cliaddr, &len);
printf("connection from %s\n", Sock_ntop((SA *)&cliaddr, len));
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));
Close(connfd);
}
}
运行以上程序,以IPv4套接字启动服务器:
以IPv6套接字启动服务器:
如上图,后两个连接使用的是IPv4映射的IPv6地址,这展示了运行在双栈主机上的IPv6服务器既能处理IPv4客户,又能处理IPv6客户。
自编写的使用getaddrinfo函数创建未连接UDP套接字的函数:
以上函数创建一个未连接UDP套接字,并返回3项数据,首先,返回值是该套接字的描述符;其次,saptr参数是指向某个由udp_client函数动态分配的套接字地址结构的指针的地址,本函数把目的IP和端口存放在这个结构中,用于稍后调用sendto;最后,这个套接字地址结构的大小在lenp参数指向的变量中返回,此参数不能是空指针,因为sendto和recvfrom调用都需要知道套接字地址结构的长度。
以下是udp_client函数的源码:
#include "unp.h"
int udp_client(const char *host, const char * serv, SA **saptr, socklen_t *lenp) {
int sockfd, n;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0) {
err_quit("udp_client error for %s, %s: %s", host, serv, gai_strerror(n));
}
ressave = res;
do {
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sockfd >= 0) {
break; /* success */
}
} while ((res = res->ai_next) != NULL);
if (res == NULL) { /* errno set from final socket() */
err_sys("udp_client error for %s, %s", host, serv);
}
*saptr = Malloc(res->ai_addrlen);
memcpy(*saptr, res->ai_addr, res->ai_addrlen);
*lenp = res->ai_addrlen;
freeaddrinfo(ressave);
return sockfd;
}
把获取时间的客户程序改为使用UDP和udp_client函数:
#include "unp.h"
int main(int argc, char **argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
socklen_t salen;
struct sockaddr *sa;
if (argc != 3) {
err_quit("usage: daytimeudpcli1 " );
}
sockfd = Udp_client(argv[1], argv[2], (void **)&sa, &salen);
printf("sending to %s\n", Sock_ntop_host(sa, salen));
Sendto(sockfd, "", 1, 0, sa, salen); /* send 1-byte datagram */
n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
recvline[n] = '\0'; /* null terminate */
Fputs(recvline, stdout);
exit(0);
}
运行以上程序,我们先指定一个拥有AAAA记录和A记录各1个的主机名,由于getaddrinfo函数首先返回的是对应AAAA记录的结构,所以应该创建一个IPv6套接字:
接着我们指定一个点分十进制地址:
我们自编写的创建一个已连接UDP套接字的函数:
由于udp_connect函数创建的是已连接UDP套接字,因此相比于udp_connect函数,udp_client函数结尾的两个参数就不需要了,调用者可用write函数代替sendto函数。
以下是udp_connect函数的源码:
#include "unp.h"
int udp_connect(const char *host, const char *serv) {
int sockfd, n;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0) {
err_quit("udp_connect error for %s, %s: %s", host, serv, gai_strerror(n));
}
ressave = res;
do {
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sockfd < 0) {
continue; /* ignore this one */
}
if (connect(sockfd, res->ai_addr, res->ai_addrlen) == 0) {
break; /* success */
}
Close(sockfd); /* ignore this one */
} while ((res = res->ai_next) != NULL);
if (res == NULL) { /* errno set from final connect() */
err_sys("udp_connect error for %s, %s", host, serv);
}
freeaddrinfo(ressave);
return sockfd;
}
以上函数几乎与tcp_connect函数相同,两者差别之一是UDP套接字上的connect调用不会发送任何东西到对端,如果存在错误(如对端不可达或指定端口上没有服务器),调用者就得等到向对端发送一个数据报后才能发现。
自编写的调用getaddrinfo的UDP接口函数udp_server:
此函数的参数与tcp_listen函数一样,有一个可选的hostname参数和一个必选的service参数,以及一个可选的指向某个socklen_t类型变量的指针,用于返回套接字地址结构的大小。
udp_server函数的源码:
#include "unp.h"
int udp_server(const char *host, const char *serv, socklen_t *addrlenp) {
int sockfd, n;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_flags = AI_PASSIVE;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0) {
err_quit("udp_server error for %s, %s: %s", host, serv, gai_strerror(n));
}
ressave = res;
do {
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sockfd < 0) {
continue; /* error - try next one */
}
if (bind(sockfd, res->ai_addr, res->ai_addrlen) == 0) {
break; /* success */
}
Close(sockfd); /* bind error - close and try next one */
} while ((res = res->ai_next) != NULL);
if (res == NULL) { /* errno from final socket() or bind() */
err_sys("udp_server error for %s, %s", host, serv);
}
if (addrlenp) {
*addrlenp = res->ai_addrlen; /* return size of protocol address */
}
freeaddrinfo(ressave);
return sockfd;
}
除了没有调用listen外,以上函数几乎等同于tcp_listen函数,我们把地址族设置成AF_UNSPEC,但调用者可用前面说到的技巧强制使用IPv4或IPv6。
以上代码中,对于UDP套接字我们不设置SO_REUSEADDR选项,本套接字选项允许在支持多播的主机上把同一UDP端口捆绑到多个套接字上。既然UDP套接字没有类似于TCP的TIME_WAIT的状态,所以启动服务器时就没有设置这个套接字选项的必要。
修改时间获取服务器程序,使其使用udp_server函数:
#include "unp.h"
#include
int main(int argc, char **argv) {
int sockfd;
ssize_t n;
char buff[MAXLINE];
time_t ticks;
socklen_t len;
struct sockaddr_storage cliaddr;
if (argc == 2) {
sockfd = Udp_server(NULL, argv[1], NULL);
} else if (argc == 3) {
sockfd = Udp_server(argv[1], argv[2], NULL);
} else {
err_quit("usage: daytimeudpsrv [] " );
}
for (; ; ) {
len = sizeof(cliaddr);
n = Recvfrom(sockfd, buff, MAXLINE, 0, (SA *)&cliaddr, &len);
printf("datagram from %s\n", Sock_ntop((SA *)&cliaddr, len));
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Sendto(sockfd, buff, strlen(buff), 0, (SA *)&cliaddr, len);
}
}
对于以上程序,如果我们在一个窗口中启动该服务器的一个实例,给它捆绑通配地址和某个端口;然后在另一窗口中启动一个客户,验证服务器可以正常处理客户请求(服务器会进行printf调用);然后在第三个窗口中启动服务器的另一个实例,给它绑定该主机的一个单播地址和与第一个服务器相同的端口,此时会无法绑定与第一个服务器相同的端口,这是因为没有设置SO_REUSEADDR套接字选项,最容易的解决办法是复制一份udp_server函数,把它命名为udp_server_reuseaddr,然后由它设置这个套接字选项,再让服务器调用这个新函数。修复该问题后,重新启动第二个服务器,启动一个客户,可以验证第二个服务器已盗用了第一个服务器的端口。然后关闭第二个服务器,然后使用一个不同的账号登录,并再次启动第二个服务器,看能否继续成功盗用,有些厂商只允许用户ID相同的进程再次捆绑之前某个进程捆绑过的端口。
getnameinfo函数是getaddrinfo函数的互补函数,它以一个套接字地址为参数,返回描述其中主机的字符串和描述其中服务的另一字符串,本函数以协议无关方式提供这些信息:
sockaddr参数指向一个套接字地址结构,其中包含待转换成直观可读字符串的协议地址,addrlen参数是这个结构的长度。该结构及其长度通常由accept、recvfrom、getsockname、getpeername函数返回。
待返回的2个直观可读字符串由调用者预先分配空间,host和hostlen参数指定主机名字符串,serv和servlen参数指定服务名字符串。如果不想返回某个串,需要将其对应的len参数设为0。
sock_ntop函数和getnameinfo函数的差别在于,前者不涉及DNS,只返回IP地址和端口号的一个可显示版本,而后者尝试获取主机和服务的名字。
以下标志可改变getnameinfo函数的操作:
当知道处理的套接字地址结构是用于数据报时,调用者应设置NI_DGRAM标志,因为套接字地址结构中只有IP地址和端口号,getnameinfo函数无法确定所用协议是TCP还是UDP。有很多端口号在TCP上用于某服务,但在UDP上用于另一个服务,如端口514,它在TCP上提供rsh服务,在UDP上提供syslog服务。
如果无法使用DNS反向解析出主机名,NI_NAMEREQD标志将导致返回一个错误。有些服务器需要把客户的IP地址映射为主机名,然后以返回的主机名调用gethostbyname,来验证得到的地址与调用getnameinfo函数指定的套接字地址结构中的地址确实是相同的。
NI_NOFQDN标志导致返回的主机名第一个点号之后的内容被截去,如gethostbyaddr返回的某IP地址的主机名是aix.unpbook.com,则设置此标志的getnameinfo函数返回的主机名是aix。
NI_NUMERICHOST标志告知getnameinfo函数不要调用DNS,因为调用DNS可能耗时,而是以数值字符串形式返回IP地址(可能通过inet_ntop函数实现)。NI_NUMERICSERV标志指定以十进制数字符串返回端口号。NI_NUMERICSCOPE标志指定以数值字符串返回范围标识,只有IPv6使用它有意义,它将数值形式的IPv6地址范围返回给调用方,如IPv6地址fe80::1%eth0
,其中%eth0
就是范围标识符,如果指定了此标志,会返回接口索引的数值形式。对于客户的端口(临时端口),应设置NI_NUMERICSERV标志。
我们可以把以上标志中有意义的组合逻辑或在一起。
gethostbyname和gethostbyaddr函数是不可重入的,函数的源码大体如下:
上图显示,3个函数共用一个static的host变量(gethostbyname2函数是gethostbyname函数的进化版本,它是在BIND 4.9.4中为支持IPv6引入的,现已被淘汰)。
在一个UNIX进程中发生重入问题的条件是,它的主控制流和某信号处理函数中同时调用gethostbyname或gethostbyaddr:
如果主控制流被SIGALRM信号暂停时正处于执行gethostbyname函数期间,如该函数已填写好host变量并即将返回,且信号处理函数随后会调用gethostbyname,那么host变量将被重用,因为该进程中只存在该变量的单个副本。这样原来由主控制流计算出的值被重写成了由信号处理函数计算出的值。
关于重入问题注意以下几点:
1.在过去,gethostbyname、gethostbyaddr、getservbyname、getservbyport函数不可重入,因为它们返回指向一个静态结构的指针。
支持线程的一些实现(如Solaris 2.x)还提供者4个函数的可重入版本,它们的名字以_r结尾。
支持线程的另一些实现(如HP-UX 10.30及以后版本)使用线程特定数据提供这些函数的可重入版本。
2.inet_pton、inet_ntop函数总是可重入的。
3.在过去,inet_ntoa函数是不可重入的,但支持线程的一些实现提供了使用线程特定数据的可重入版本。
4.getaddrinfo函数可重入的前提是它调用的函数都可重入,即它应该调用可重入版本的gethostbyname函数以解析主机名,调用可重入版本的getservbyname函数以解析服务名。getaddrinfo函数动态分配结果结构的原因是令该函数变得可重入。
5.getnameinfo函数可重入的前提是它调用的函数都可重入,即它应该调用gethostbyaddr函数以反向解析主机名,调用可重入版本的getservbyport函数以反向解析服务名。getnameinfo函数的2个结果字符串由调用者分配存储空间,从而使它可重入。
errno变量也有同样问题,这个整型变量在过去每个进程各有一个副本,如果一个进程执行的某个系统调用返回错误,该进程的errno就被存入一个整数错误码,例如,当调用C标准库的close函数时,进程可能执行如下伪代码:
1.把系统调用的参数(一个整数描述符)置于一个寄存器。
2.把一个值置于另一个寄存器,以指出close系统调用将被调用。
3.调用该系统调用(用一条特殊指令切换到内核态)。
4.测试一个寄存器的值以判定是否发生过某个错误。
5.如果没有错误则执行return 0。
6.否则把另外某个寄存器的值存入errno。
7.执行return -1。
我们注意到如果没有错误发生,则errno的值不会变,因此除非发生了错误(通常由函数返回-1指示),否则不应查看errno的值。
如果一个程序先测试close函数的返回值,判定发生了错误后,再显示errno的值:
if (close(fd) < 0) {
fprintf("stderr, "close error, errno = %d\n", errno);
exit(1);
}
从系统调用返回时把错误码存入errno到稍后程序显示errno值之间存在一个时间窗口,期间同一个进程内的另一个线程(如信号处理函数的某次调用)可能改变了errno的值。
上述gethostbyname函数的问题可以不在信号处理函数中调用不可重入的函数来解决。上述errno问题可在信号处理函数中预先保存并事后恢复errno值来解决:
void sig_alrm(int signo) {
int errno_save;
errno_save = errno; /* save its value on entry */
if (write( ... ) != nbytes) {
fprintf(stderr, "write error, errno = %d\n", errno);
}
errno = errno_save; /* restore its value on return */
}
上例代码中,在信号处理函数中还调用了fprintf函数,它引入了另一个重入问题。许多版本的标准IO库函数是不可重入的,即我们不应该从信号处理函数中调用标准IO函数。
有两种方法可以把gethostbyname函数这类不可重入的函数改为可重入函数:
1.把由不可重入函数填写并返回静态接收的做法改为由调用者分配再有可重入函数填写结构。这就是把不可重入的gethostbyname函数改为可重入的gethostbyname_r函数所用的技巧。但这种方法比较复杂,因为不仅调用者必须提供待填写的hostent结构,还要提供一个存放该结构指向的信息(如规范名字、别名指针数组、各个别名字符串、地址指针数组及该指针指向的各个in_addr结构)的大缓冲区,函数填写的内容包括多个指向这个大缓冲区的指针。这样函数至少要增设3个参数:指向待填写的hostent结构的指针、指向大缓冲区的指针、该大缓冲区的大小,gethostbyname_r函数还增加了第四个额外参数,用于存放错误码的某整型变量的指针,这第四个额外参数也是必要的,因为全局变量h_errno也不可用了(与errno一样,有可重入性问题)。
getnameinfo和inet_ntop函数也用这种方法。
2.直接由可重入函数调用malloc以动态分配空间。这是getaddrinfo函数所用的技巧。这种方法的问题是调用该函数的进程必须调用freeaddrinfo来释放动态分配的空间,如果不这么做会导致内存泄漏,即进程每调用一次动态分配内存空间的函数,所用内存量就相应增长,如果进程长时间运行(网络服务器的特性之一),那么内存耗用量会不断增加。
以下讨论Solaris 2.x用于从名字到地址和从地址到名字解析的可重入函数:
每个函数都需要4个额外参数:
1.result参数指向由调用者分配并由被调用函数填写的hostent结构,成功返回时本指针同时作为函数的返回值返回;
2、3.buf参数指向由调用者分配且大小为buflen的缓冲区。该缓冲区用于存放规范主机名、别名指针数组、各个别名字符串、地址(in_addr结构)指针数组、各个实际地址(in_addr结构)。由result参数指向的hostent机构中所有指针都指向该缓冲区内部。但对于该缓冲区大小,大多手册页面只是含糊地说“该缓冲区必须足够大以存放于hostent结构关联的所有数据”。gethostbyname函数当前的实现最多能返回35个别名指针和35个地址指针,且内部用了8192字节的缓冲区存放这些别名和地址,因此大小为8192字节的缓冲区应该足够了。
4.如果出错,错误码通过h_errnop指针参数返回,而非全局变量h_errno。
但重入问题比表面看更严重,首先,关于gethostbyname和gethostbyaddr函数的重入问题无标准可循,POSIX声明这两个函数不必是可重入的,Unix98只说这两个函数不必是线程安全的。
其次,关于_r函数也没有标准可循。上例的_r函数由Solaris 2.x提供。Linux也提供类似的_r函数,但函数会通过其倒数第二个值-结果参数返回一个hostent结构,该函数将查找操作的成功与否作为函数的返回值,同时也将是否成功保存在h_errno参数中。Digital Unix 4.0和HP-UX 10.30也提供这两个函数的_r版本,只是参数不同,它们的版本与Solaris版本有同样的前2个参数,但Solaris版本的后3个参数在前者中组合成一个新hostent_data结构(它由调用者分配空间),指向该hostent_data结构的指针组成本函数的第三个兼最后一个参数。Digital Unix 4.0和HP-UX 10.30中普通的gethostbyname和gethostbyaddr函数通过使用线程特定数据,也是可重入的。
最后,虽然gethostbyname函数的可重入版本可能会在不同的线程同时调用时提供安全性,但这并不意味着底层解析器函数(用于进行主机名解析的函数)也具有可重入性。
开发IPv6期间,用于查找IPv6地址的API经历了多次反复,这些早期API既复杂又没有足够灵活性,于是在RFC 2553中被淘汰掉。RFC 2553又引入了新函数,它们最终在RFC 3493中被简单替换成getaddrinfo和getnameinfo函数。以下介绍一些早期API,以便转换已经使用它们的程序。
gethostbyname函数没有像getaddrinfo函数的hints.ai_family这种指定我们所关心的地址族的参数,因此第一版gethostbyname函数API的修改使用RES_USE_INET6常值,此常量必须使用一个内部私有的接口将其添加到解析器标志中。这个API不便于移植,因为使用不同内部解析器接口的系统需要模仿BIND解析器接口。
启用RES_USE_INET6会使gethostbyname函数先查找AAAA记录,若找不到AAAA记录,则接着查找A记录。因为hostent结构只有一个地址长度字段,因此gethostbyname函数要么只返回IPv6地址,要么只返回IPv4地址,不能同时返回这两种地址。
启用RES_USE_INET6还会使gethostbyname2函数以IPv4映射的IPv6地址形式返回IPv4地址。
gethostbyname2函数给gethostbyname函数增加了一个地址族参数:
当af参数为AF_INET时,gethostbyname2函数的行为与gethostbyname函数一样,即查找并返回IPv4地址。当af参数为AF_INET6时,gethostbyname2函数只查找AAAA记录并返回IPv6地址。
RFC 2553因为RES_USE_INET6标志的全局特性以及想对返回信息进行更多控制而废除了RES_USE_INET6和gethostbyname2函数,并引入了getipnodebyname函数:
af和flags参数直接映射到getaddrinfo的hints.ai_family和hints.ai_flags参数。为了线程安全,返回值是动态分配的,因此必须使用freehostent函数释放:
getipnodebyname和getipnodebyaddr函数被RFC 3493废除,并代之以getaddrinfo和getnameinfo函数。
进程可能想查找四类与网络相关的信息:主机、网络、协议、服务。大多查找针对的是主机(gethostbyname和gethostbyaddr函数),一小部分查找针对的是服务(getservbyname和getservbyport函数),更小一部分查找针对的是网络和协议。
这四类信息可存放在文件中,每类信息都定义了三个访问函数:
1.函数getXXXent读出文件中的下一个表项,如文件未打开,则会先打开文件。
2.函数setXXXent打开文件(如尚未打开),并rewind文件。
3.函数endXXXent关闭文件。
每类信息都定义了各自的结构,包括hostent、netent、protoent、servent,这些定义通常在头文件netdb.h中。
除了用于顺序处理文件的get、set、end三个函数外,每类信息还提供一些键值查找函数。这些函数顺序遍历整个文件(如通过getXXXent函数读出每一行),但只把某个参数匹配的表项返回调用者。这些键值查找函数具有getXXXbyYYY的名字。例如,针对主机信息的两个键值查找函数为gethostbyname(查找匹配某个主机名的表项)和gethostbyaddr(查找匹配某个IP地址的表项)。
只有主机和网络信息可通过DNS获取,协议和服务信息总是从相应文件中读取。不同实现有不同方法供系统管理员指定是使用DNS还是使用文件来查找主机和网络信息。
如果使用DNS来查找主机和网络信息,则只有键值查找函数有意义,你不能使用gethostent函数并期待顺序遍历DNS中所有表项。调用gethostent时,它仅读取/etc/hosts文件,不会访问DNS。
虽然网络信息可以做成通过DNS访问到,但很少有人这么做。典型的做法是,系统管理员创建并维护一个/etc/networks文件,网络信息通过它而非DNS获取。如果存在这个文件,指定-i选项的netstat程序就使用它显示每个网络的名字。但无类寻址使得获取网络信息的函数几近无用,且这些函数也不支持IPv6,因此新的网络应用应避免使用网络名字。
应用把主机名转换成IP地址或做相反转换的一组函数称为解析器,gethostbyname和gethostbyaddr函数是解析器曾经常用的入口。随着向IPv6和线程化编程模型的转移,getaddrinfo和getnameinfo函数更为有用,因为它们既能解析IPv6地址,又符合线程安全调用约定。
处理服务名和端口号的常用函数是getservbyname,它接受一个服务名作为参数,并返回一个包含相应端口号的结构。这种映射关系通常包含在一个文本文件中。还有用于把协议名映射成协议号、把网络名映射为网络号的函数,但很少使用。
有一种代替gethostbyname和gethostbyaddr函数的方法我们没有提到,即直接调用解析器函数。这样直接使用DNS的程序之一是sendmail,因为它需要搜索MX资源记录,这是gethostbyxxx函数无法做到的。解析器函数都有以res_开头的名字,如res_init函数,可执行man resolver
命令得到这些函数的手册页面。
getaddrinfo函数可允许我们编写协议无关的代码,但直接调用它要花很多步骤,且对于不同情形仍有反复出现的细节要处理,如遍历所有返回的结构、忽略socket函数返回的错误、为TCP服务器设置SO_REUSEADDR套接字选项等,因此我们编写了5个访问getaddrinfo函数的接口函数tcp_connect、tcp_listen、udp_client、udp_connect、udp_server,以简化这些细节。
gethostbyname和gethostbyaddr函数通常是不可重入的,这两个函数使用同一个静态的结果结构,这意味着如果先后调用这两个函数各一次,后一次调用会覆盖前一次调用的结果。因此一些厂商提供这两个函数的_r版本,但需要对调用这些函数的应用加以修改。
修改11-3中的程序,为每个返回的地址调用gethostbyaddr,然后显示由它返回的h_name:
#include "unp.h"
int main(int argc, char **argv) {
char *ptr, **pptr;
char str[INET6_ADDRSTRLEN];
struct hostent *hptr;
while (--argc > 0) {
ptr = *++argv;
if ((hptr = gethostbyname(ptr)) == NULL) {
err_msg("gethostbyname error for host: %s: %s", ptr, hstrerror(h_errno));
continue;
}
printf("official hostname: %s\n", hptr->h_name);
for (pptr = hptr->h_aliases; *pptr != NULL; ++pptr) {
printf(" alias: %s\n", *pptr);
}
switch (hptr->h_addrtype) {
case AF_INET:
#ifdef AF_INET6
case AF_INET6:
#endif
pptr = hptr->h_addr_list;
for (; *pptr != NULL; ++pptr) {
printf("\taddress: %s\n", Inet_ntop(hptr->h_addrtype, *pptr, str, sizeof(str)));
// 与11-3中代码相比,多了以下if-else语句
if ((hptr = gethostbyaddr(*pptr, hptr->h_length, hptr->h_addrtype)) == NULL) {
printf("\t(gethostbyaddr failed)\n");
} else if (hptr->h_name != NULL) {
printf("\tname = %s\n", hptr->h_name);
} else {
printf("\t(no hostname returned by gethostbyaddr)\n");
}
}
break;
default:
err_ret("unknown address type");
break;
}
}
exit(0);
}
以上程序针对只有一个IP地址的主机没有问题,如果针对拥有8个IP地址的主机调用11-3中的程序:
但如果我们对该主机运行上图中的程序,则它只会输出一个IP地址:
问题在于gethostbyname和gethostbyaddr两个函数共享同一个hostent结构,当以上程序调用gethostbyname后,再次调用gethostbyaddr时,它重用了这个结构以及它指向的存储区(即h_addr_list指针数组及由该数组指向的数据),结果冲掉了其余7个地址。
在我的机器上(Linux rh 2.6.39-400.17.1.el6uek.x86_64 #1 SMP Fri Feb 22 18:16:18 PST 2013 x86_64 x86_64 x86_64 GNU/Linux
)运行以上程序时,会出现段错误,应该是指针已失效:
有两种方法可以修复上例问题:
1.如果系统支持,可使用可重入版本的gethostbyaddr和gethostbyname函数。
2.在调用gethostbyaddr前复制由gethostbyname返回的指针数组及该数组中元素指向的数据。
chargen服务器一直向客户发送数据,直到客户关闭连接为止。