自己动手实现DNS协议

在本博客的DNS协议详解及报文格式分析一文中介绍了DNS的基本理论,DNS协议的报文格式等,如果详细了解了的话,不免会萌生出自己实现DNS协议的想法。要知道DNS协议是基于UDP的,如果能够自己组装出一个合法有效的DNS报文,便可以通过socket将DNS查询报文发出去,并能得到相应的域名服务器的响应报文,对响应报文进行解析,便可以得到最终的IP地址。本文基于此,介绍了实现DNS协议的思路,给出了完整的可运行代码。废话不多说,马上开始。

1. 协议头部结构定义

首先需要根据 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 函数做转换,后面的报文组装代码里有写到。
  • 如果仔细对比会发现标志字段的书写顺序比较怪异。比如标志字段的第一个字节,在DNS报文中顺序应该是 <QR-opcode-AA-TC-RD>(注:按照报文中内容的顺序,QR是低位,RD是高位),而上面的代码中顺序是 <RD-TC-AA-OPCODE-QR> ,这是因为我们定义各个位时使用了C/C++中的位域语法。位域中将高位放在了前面,将低位放在了后面,比如:1011 0010B,如果用下面的 BitFieldDemo 所示的位域结构表示的话,则 a == 10B, b == 110B, c == 010B 。所以,<RD-TC-AA-OPCODE-QR> 这样的定义其实表明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位 
};

2. 查询报文组装与发送

万事开头难,头部数据结构定义好了之后,后面就好办多了,无非就是将标志以及需要查询的内容(主要是域名)填充到头部和正文的Queries字段,然后使用socket发出去即可。完整代码见下面SendDnsPack所示,本节给出的查询报文组装与发送实现代码中,是以A类型(0x1)为例的,A类型表示由域名查询获得IPv4地址。

主要分为以下两个大的步骤:

  1. 根据第1节定义的DNS报文头部,组装查询报文
  2. 使用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;
}

代码中有几个地方需要注意一下:

  • 关于域名合法性的判断,域名的开头不能有点号,但是域名的结尾是允许有一个点号的,结尾的点号其实表示的是根域名服务器,在本博客 DNS协议详解及报文格式分析 这篇文章有讲到
  • 因为最终发出的报文中的Queries字段中,查询的名字格式是类似 6 j o c e n t 2 m e 0这样的,所以有一部分代码是做这个转换的
  • Type, Class,Questions  都是两个字节,为了转换成网路字节序,需要使用 htons 函数

3. 响应报文接收与解析

当成功的向DNS服务端发出查询报文后,接下来就是等待响应报文,DNS响应报文的格式与查询报文相比,头部标志字段QR由0变成了1,正文部分多了些字段,比如Answers字段等。下文中的RecvDnsPack即是响应报文接收与解析的代码。

主要分为以下两个大的步骤:

  1. 使用recvfrom 函数接收服务端返回的内容
  2. 解析收到的内容,主要是从中获取IP地址。解析的过程中首先对收到内容的合法性做了一些校验,分别校验了内容长度、ID号、QR值等;然后使用指针遍历的方式依次解析响应报文中的内容

代码中注释已经相当详细,不再赘述。

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;
            }
        }
    }
}

4. 测试

本小节给出上述发送函数与接受函数的测试代码,测试的过程中可以用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下编译可能需要对代码做略微调整,但整体结构应该是一样的,知悉。

 




发表评论

电子邮件地址不会被公开。 必填项已用*标注