写的太好了,多谢原文作者的贡献,在此感谢!本文转自https://jocent.me/2017/06/25/dns-protocol-implementation.html
在本博客的DNS协议详解及报文格式分析一文中介绍了DNS的基本理论,DNS协议的报文格式等,如果详细了解了的话,不免会萌生出自己实现DNS协议的想法。要知道DNS协议是基于UDP的,如果能够自己组装出一个合法有效的DNS报文,便可以通过socket
将DNS查询报文发出去,并能得到相应的域名服务器的响应报文,对响应报文进行解析,便可以得到最终的IP地址。本文基于此,介绍了实现DNS协议的思路,给出了完整的可运行代码。废话不多说,马上开始。
首先需要根据 DNS协议详解及报文格式分析 一文的介绍,将DNS的头部数据结构构造出来。DNS头部实际上只有三个部分的内容:会话标识(2字节),标志(2字节)和数量字段(共8字节),这个头部是最终的发送或是接收报文的头部,所以采用的是网络字节序。下面给出代码。
#pragma pack(push, 1) struct DNSHeader { /* 1. 会话标识(2字节)*/ unsigned short usTransID; // Transaction ID /* 2. 标志(共2字节)*/ unsigned char RD : 1; // 表示期望递归,1bit unsigned char TC : 1; // 表示可截断的,1bit unsigned char AA : 1; // 表示授权回答,1bit unsigned char opcode : 4; // 0表示标准查询,1表示反向查询,2表示服务器状态请求,4bit unsigned char QR : 1; // 查询/响应标志位,0为查询,1为响应,1bit unsigned char rcode : 4; // 表示返回码,4bit unsigned char zero : 3; // 必须为0,3bit unsigned char RA : 1; // 表示可用递归,1bit /* 3. 数量字段(共8字节) */ unsigned short Questions; // 问题数 unsigned short AnswerRRs; // 回答资源记录数 unsigned short AuthorityRRs; // 授权资源记录数 unsigned short AdditionalRRs; // 附加资源记录数 }; #pragma pack(pop)
上述代码有几个地方需要注意一下:
#pragma pack(push, 1)
和 #pragma pack(pop)
。使结构体按1字节方式对齐,其中push
表示把原来的对齐方式压栈,pop
表示恢复原来的对齐方式。 usTransID、Questions、AnswerRRs...
这些两个字节的字段,由于是网络字节序,所以在给这些字段填充内容时,需要使用 htons
函数做转换,后面的报文组装代码里有写到。 (注:按照报文中内容的顺序,QR是低位,RD是高位)
,而上面的代码中顺序是
,这是因为我们定义各个位时使用了C/C++中的位域语法。位域中将高位放在了前面,将低位放在了后面,比如:1011 0010B
,如果用下面的BitFieldDemo
所示的位域结构表示的话,则 a == 10B, b == 110B, c == 010B
。所以,
这样的定义其实表明RD
是高位,QR
是低位,正好符合了DNS的头部标志字段要求。标志字段的第二个字节类似。struct BitFieldDemo { // 假如有二进制数1011 0010B,左边为高位,右边为低位 // 则a == 10B, b == 110B, c == 010B unsigned char a : 2; // 高2位 unsigned char b : 3; unsigned char c : 3; // 低2位 };
万事开头难,头部数据结构定义好了之后,后面就好办多了,无非就是将标志以及需要查询的内容(主要是域名)填充到头部和正文的Queries
字段,然后使用socket
发出去即可。完整代码见下面SendDnsPack
所示,本节给出的查询报文组装与发送实现代码中,是以A类型(0x1
)为例的,A类型表示由域名查询获得IPv4地址。
主要分为以下两个大的步骤:
sendto
函数将报文发送到DNS服务器的53号端口// @Brief : 发送DNS查询报文 // @Param: usID: 报文ID编号 // pSocket: 需要发送的socket // szDnsServer: DNS服务器地址 // szDomainName: 需要查询的域名 // @Retrun: true表示发送成功,false表示发送失败 bool SendDnsPack(IN unsigned short usID, IN SOCKET *pSocket, IN const char *szDnsServer, IN const char *szDomainName) { bool bRet = false; if (*pSocket == INVALID_SOCKET || szDomainName == NULL || szDnsServer == NULL || strlen(szDomainName) == 0 || strlen(szDnsServer) == 0) { return bRet; } unsigned int uiDnLen = strlen(szDomainName); // 判断域名合法性,域名的首字母不能是点号,域名的 // 最后不能有两个连续的点号 if ('.' == szDomainName[0] || ( '.' == szDomainName[uiDnLen - 1] && '.' == szDomainName[uiDnLen - 2]) ) { return bRet; } /* 1. 将域名转换为符合查询报文的格式 */ // 查询报文的格式是类似这样的: // 6 j o c e n t 2 m e 0 unsigned int uiQueryNameLen = 0; BYTE *pbQueryDomainName = (BYTE *)malloc(uiDnLen + 1 + 1); if (pbQueryDomainName == NULL) { return bRet; } // 转换后的查询字段长度为域名长度 +2 memset(pbQueryDomainName, 0, uiDnLen + 1 + 1); // 下面的循环作用如下: // 如果域名为 jocent.me ,则转换成了 6 j o c e n t ,还有一部分没有复制 // 如果域名为 jocent.me.,则转换成了 6 j o c e n t 2 m e unsigned int uiPos = 0; unsigned int i = 0; for ( i = 0; i < uiDnLen; ++i) { if (szDomainName[i] == '.') { pbQueryDomainName[uiPos] = i - uiPos; if (pbQueryDomainName[uiPos] > 0) { memcpy(pbQueryDomainName + uiPos + 1, szDomainName + uiPos, i - uiPos); } uiPos = i + 1; } } // 如果域名的最后不是点号,那么上面的循环只转换了一部分 // 下面的代码继续转换剩余的部分, 比如 2 m e if (szDomainName[i-1] != '.') { pbQueryDomainName[uiPos] = i - uiPos; memcpy(pbQueryDomainName + uiPos + 1, szDomainName + uiPos, i - uiPos); uiQueryNameLen = uiDnLen + 1 + 1; } else { uiQueryNameLen = uiDnLen + 1; } // 填充内容 头部 + name + type + class DNSHeader *PDNSPackage = (DNSHeader*)malloc(sizeof(DNSHeader) + uiQueryNameLen + 4); if (PDNSPackage == NULL) { goto exit; } memset(PDNSPackage, 0, sizeof(DNSHeader) + uiQueryNameLen + 4); // 填充头部内容 PDNSPackage->usTransID = htons(usID); // ID PDNSPackage->RD = 0x1; // 表示期望递归 PDNSPackage->Questions = htons(0x1); // 本文第一节所示,这里用htons做了转换 // 填充正文内容 name + type + class BYTE* PText = (BYTE*)PDNSPackage + sizeof(DNSHeader); memcpy(PText, pbQueryDomainName, uiQueryNameLen); unsigned short *usQueryType = (unsigned short *)(PText + uiQueryNameLen); *usQueryType = htons(0x1); // TYPE: A ++usQueryType; *usQueryType = htons(0x1); // CLASS: IN // 需要发送到的DNS服务器的地址 sockaddr_in dnsServAddr = {}; dnsServAddr.sin_family = AF_INET; dnsServAddr.sin_port = ::htons(53); // DNS服务端的端口号为53 dnsServAddr.sin_addr.S_un.S_addr = ::inet_addr(szDnsServer); // 将查询报文发送出去 int nRet = ::sendto(*pSocket, (char*)PDNSPackage, sizeof(DNSHeader) + uiQueryNameLen + 4, 0, (sockaddr*)&dnsServAddr, sizeof(dnsServAddr)); if (SOCKET_ERROR == nRet) { printf("DNSPackage Send Fail! \n"); goto exit; } // printf("DNSPackage Send Success! \n"); bRet = true; // 统一的资源清理处 exit: if (PDNSPackage) { free(PDNSPackage); PDNSPackage = NULL; } if (pbQueryDomainName) { free(pbQueryDomainName); pbQueryDomainName = NULL; } return bRet; }
代码中有几个地方需要注意一下:
Queries
字段中,查询的名字格式是类似 6 j o c e n t 2 m e 0
这样的,所以有一部分代码是做这个转换的当成功的向DNS服务端发出查询报文后,接下来就是等待响应报文,DNS响应报文的格式与查询报文相比,头部标志字段QR由0变成了1,正文部分多了些字段,比如Answers字段
等。下文中的RecvDnsPack
即是响应报文接收与解析的代码。
主要分为以下两个大的步骤:
recvfrom
函数接收服务端返回的内容代码中注释已经相当详细,不再赘述。
void RecvDnsPack(IN unsigned short usId, IN SOCKET *pSocket ) { if (*pSocket == INVALID_SOCKET) { return; } char szBuffer[256] = {}; // 保存接收到的内容 sockaddr_in servAddr = {}; int iFromLen = sizeof(sockaddr_in); int iRet = ::recvfrom(*pSocket, szBuffer, 256, 0, (sockaddr*)&servAddr, &iFromLen); if (SOCKET_ERROR == iRet || 0 == iRet) { printf("recv fail \n"); return; } /* 解析收到的内容 */ DNSHeader *PDNSPackageRecv = (DNSHeader *)szBuffer; unsigned int uiTotal = iRet; // 总字节数 unsigned int uiSurplus = iRet; // 接受到的总的字节数 // 确定收到的szBuffer的长度大于sizeof(DNSHeader) if (uiTotal <= sizeof(DNSHeader)) { printf("接收到的内容长度不合法\n"); return; } // 确认PDNSPackageRecv中的ID是否与发送报文中的是一致的 if (htons(usId) != PDNSPackageRecv->usTransID) { printf("接收到的报文ID与查询报文不相符\n"); return; } // 确认PDNSPackageRecv中的Flags确实为DNS的响应报文 if ( 0x01 != PDNSPackageRecv->QR ) { printf("接收到的报文不是响应报文\n"); return; } // 获取Queries中的type和class字段 unsigned char *pChQueries = (unsigned char *)PDNSPackageRecv + sizeof(DNSHeader); uiSurplus -= sizeof(DNSHeader); for ( ; *pChQueries && uiSurplus > 0; ++pChQueries, --uiSurplus ) { ; } // 跳过Queries中的name字段 ++pChQueries; --uiSurplus; if ( uiSurplus < 4 ) { printf("接收到的内容长度不合法\n"); return; } unsigned short usQueryType = ntohs( *((unsigned short*)pChQueries) ); pChQueries += 2; uiSurplus -= 2; unsigned short usQueryClass = ntohs( *((unsigned short*)pChQueries) ); pChQueries += 2; uiSurplus -= 2; // 解析Answers字段 unsigned char *pChAnswers = pChQueries; while (0 < uiSurplus && uiSurplus <= uiTotal) { // 跳过name字段(无用) if ( *pChAnswers == 0xC0 ) // 存放的是指针 { if (uiSurplus < 2) { printf("接收到的内容长度不合法\n"); return; } pChAnswers += 2; // 跳过指针字段 uiSurplus -= 2; } else // 存放的是域名 { // 跳过域名,因为已经校验了ID,域名就不用了 for ( ; *pChAnswers && uiSurplus > 0; ++pChAnswers, --uiSurplus ) {;} pChAnswers++; uiSurplus--; } if (uiSurplus < 4) { printf("接收到的内容长度不合法\n"); return; } unsigned short usAnswerType = ntohs( *((unsigned short*)pChAnswers) ); pChAnswers += 2; uiSurplus -= 2; unsigned short usAnswerClass = ntohs( *( (unsigned short*)pChAnswers ) ); pChAnswers += 2; uiSurplus -= 2; if ( usAnswerType != usQueryType || usAnswerClass != usQueryClass ) { printf("接收到的内容Type和Class与发送报文不一致\n"); return; } pChAnswers += 4; // 跳过Time to live字段,对于DNS Client来说,这个字段无用 uiSurplus -= 4; if ( htons(0x04) != *(unsigned short*)pChAnswers ) { uiSurplus -= 2; // 跳过data length字段 uiSurplus -= ntohs( *(unsigned short*)pChAnswers ); // 跳过真正的length pChAnswers += 2; pChAnswers += ntohs( *(unsigned short*)pChAnswers ); } else { if (uiSurplus < 6) { printf("接收到的内容长度不合法\n"); return; } uiSurplus -= 6; // Type为A, Class为IN if ( usAnswerType == 1 && usAnswerClass == 1) { pChAnswers += 2; unsigned int uiIP = *(unsigned int*)pChAnswers; in_addr in = {}; in.S_un.S_addr = uiIP; printf("IP: %s\n", inet_ntoa(in)); pChAnswers += 4; } else { pChAnswers += 6; } } } }
本小节给出上述发送函数与接受函数的测试代码,测试的过程中可以用Wireshark抓包看下发包和收包的情况,能够加深理解。测试程序运行结果如右图所示:
int main( int argc, char* argv[]) { WSADATA wsaData = {}; if ( 0 != ::WSAStartup(MAKEWORD(2, 2), &wsaData) ) { printf("WSAStartup fail \n"); return -1; } SOCKET socket = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (INVALID_SOCKET == socket) { printf("socket fail \n"); return -1; } int nNetTimeout = 2000; // 设置发送时限 ::setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, (char *)&nNetTimeout, sizeof(int)); // 设置接收时限 ::setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, (char *)&nNetTimeout,sizeof(int)); // 随机生成一个ID srand((unsigned int)time(NULL)); unsigned short usId = (unsigned short)rand(); // 自定义需要查询的域名 char szDomainName[256] = {}; printf("输入要查询的域名:"); scanf("%s", szDomainName); // 发送DNS报文,因为测试,这里就简单指定8.8.8.8作为查询服务器 if (!SendDnsPack(usId, &socket, "8.8.8.8", szDomainName)) { return -1; } // 接收响应报文,并显示获得的IP地址 RecvDnsPack(usId, &socket); closesocket(socket); WSACleanup(); return 0; }
P.S. 本文代码所用的编译链接环境是Windows下的VC编译环境,如果在Linux下编译可能需要对代码做略微调整,但整体结构应该是一样的,知悉。