用c语言实现的一个dns客户端

·

6 min read

用c语言实现的一个dns客户端

DNS可以帮助我们把域名映射到一个IP地址上,或者查询一个IP地址下有那些域名,使用域名访问一个网站或者服务器是一件很平常的事情,很少有人关心域名变成IP地址的实际过程,本文将使用C语言实现一个基本的DNS解析器,通过与DNS服务器的通信完成将一个域名转换成IP地址的过程,本文将提供完整的源程序;阅读本文需要有一定的网络编程基础,熟悉基本的socket编程并对DNS有一些了解,本文对网络编程的初学者难度较大。

1. 目标

  • 本文要实现一个DNS的客户端解析器(DNS resolver),意即通过直接与DNS服务器通讯,将一个域名转换成其所对应的IP地址;
  • 对DNS客户端解析器的要求:
    1. 命令行接受用户输入的域名
    2. 向DNS服务器发出查询请求,并将查询结果显示在屏幕上
    3. 仅查询域名的A记录(QTYPE=HOST,QCLASS=IN),后面会讨论相关细节
    4. 如果查询结果有多条记录,要求显示所有查询结果
    5. 如果查询的域名为别名(Alias),要求显示其实际域名(Canonical Name)
    6. 仅查询IPv4地址。
  • 在C语言编程中,当需要将一个域名转换成IP地址时,通常是使用getaddrbyname()或者getaddrinfo()函数,这两个函数会使用系统设定的DNS服务器,本文实现的DNS客户端将使用自己定义的DNS服务器;
  • 一个DNS的客户端无非就是按照一定的格式向DNS服务器发送一个报文,然后接收来自DNS服务器的响应,并解析收到的信息,从而获得结果。

2. DNS协议

  • 要编写一个DNS客户端程序,了解DNS协议是必须的,本节将仅对我们有用的有关DNS协议中的内容加以说明;看协议是枯燥的,也可以先不看,到后面需要时再回来查阅;
  • DNS协议的主要内容包含在下面两个文件中

  • rfc 1035中对一些参数的最大值做了限制

    • labels - 最多63个字符
    • names - 最多255字符
    • TTL - 32bit有符号数字,只能是正数
    • UDP messages - 最多512个字符
  • 这些限制告诉我们:

    1. 一个域名最长为255个字符,以'.'分开的各个部分,每部分最多63个字符
    2. 使用UDP与DNS通信时,每个报文的长度不能超过512个字节
    3. TTL(Time To Live),指查询到的一个DNS信息的生命周期,过了这个时间,这条信息即为作废,应该重新查询;
  • 在DNS的各种文章中,会经常看到RR的表述,这个是Resource Record的缩写,从DNS服务器返回的各种信息,都会存储在RR中;

  • RR其实就是一个符合某种格式的数据结构,RR是DNS协议中非常重要的一个结构,rfc 1035(3.2)中,对RR做了定义:
                                    1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                                               |
    /                                               /
    /                      NAME                     /
    |                                               |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      TYPE                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     CLASS                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      TTL                      |
    |                                               |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                   RDLENGTH                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
    /                     RDATA                     /
    /                                               /
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    
  • 其中

    • NAME - 拥有者名称,意即该资源记录RR所属的节点名称;实际上就是个域名,这个字段的长度是可变的,下面会详细说明其记录方式;
    • TYPE - RR的类别,占2个字节;常用的TYPE如下,更多信息请查阅rfc 1035第3.2.2;本文中,会用到TYPE=A和TYPE=CNAME两种;

      TYPE  VALUE   MEANING
      -----------------------------
      A       1     主机地址
      CNAME   5     别名的正式名称
      MX      15    邮件交换
      TXT     16    文本信息
      
    • CLASS - RR的适用的网络类别;CLASS的值常用的只有一个,即CLASS=IN,其值为1,表示互联网(Internet)

    • TTL - 32bit的正整数表示该RR可以被缓存的时长,以秒为单位;该值为0时表示该RR只能用于当前事务,不能被缓存;
    • RDLENGTH - 16bit无符号整数;该值表示RDATA字段的长度(字节数);
    • RDATA - 用于描述资源的可变长度字串;其格式取决于TYPE和CLASS字段的值,比如当TYPE=A时,RDATA中是一个32bit的IP地址。
  • rfc 1035的第4章定义了DNS协议的消息格式,向DNS服务器发送的查询报文以及DNS服务器的返回报文都符合这个格式;

    +---------------------+
    |        Header       |
    +---------------------+
    |       Question      | the question for the name server
    +---------------------+
    |        Answer       | RRs answering the question
    +---------------------+
    |      Authority      | RRs pointing toward an authority
    +---------------------+
    |      Additional     | RRs holding additional information
    +---------------------+
    
  • 在这个报文格式中,不管是查询请求报文还是应答报文,都会有一个报头(Header)、在查询请求报文中,显然不需要有Answer、Authority和Additional三部分;
  • Question部分有自己的格式,Answer、Authority和Additional这三部分的格式是一样的,下面将就这三种格式(Header、Question、Answer)做个简要说明;
  • 如果觉着这部分枯燥也可以先跳过,等看不懂程序时才回来查阅;

  • Header的格式

    • Header部分占了12个字节
                                      1  1  1  1  1  1
        0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                      ID                       |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                    QDCOUNT                    |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                    ANCOUNT                    |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                    NSCOUNT                    |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                    ARCOUNT                    |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      
    • 其中

      • ID - 随机标识,16bit长,随便填一个数即可;
      • QR - Query Response;0表示该报文为查询报文,1表示该报文为应答报文;
      • Opcode - Operation Code;表示报文的查询类型,该值由查询的发起方设置,并复制到应答报文中;0表示一个标准查询(QUERY),1表示一个反向查询(IQUERY),2表示查询服务器状态(STATUS),3-15备用;
      • AA - Authoritative Answer;权威答案,该位仅在响应报文中有效,该位为1表示当前名称服务器对所查询的域名具有权威性;
      • TC - TrunCation;该位为1表示该报文不完整;由于该报文的长度过长,超过了传输通道允许的最大长度,该报文被截断;
      • RD - Recursion Desired;该位为1表示要求DNS服务器做递归查询;如果在查询报文中设置了RD,在应答报文中将复制该位;
      • RA - Recursion Available;递归可用,在应答报文中将该位置1表示名称服务器支持递归查询,将该位清0表示不支持递归查询(因为协议规定递归查询功能是可选的);
      • Z - Reserved;备用
      • RCODE - Response code;响应码,在相应报文中设置,按照 rfc 1035 的定义,其值的含义如下:

        |值|含义| |:--:|:----| |0|没有错误| |1|格式错误 - 名称服务器无法解释查询报文| |2|服务器故障 - 由于服务器故障,无法处理此查询| |3|名称错误 - 仅对来自权威名称服务器的响应有意义,表示查询的域名不存在| |4|功能未实现 - 名称服务器不支持所请求的查询类型| |5|拒绝 - 名称服务器出于政策原因拒绝执行指定的操作| |6-15|备用|

      • QDCOUNT - 无符号16位整数,表示该报文中 QUESTION部分 有多少条查询请求;

      • ANCOUNT - 无符号16位整数,表示该报文中 ANSWER部分 有多少条RR(Resource Record);
      • NSCOUNT - 无符号16位整数,表示该报文中 Authority部分 有多少条有权威性的RR(Resource Record)
      • ARCOUNT - 无符号16位整数,表示该报文中 Additional部分 有多少条附加的RR(Resource Record)
  • Question的格式

    • Question部分是可变长度的
                                      1  1  1  1  1  1
        0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                                               |
      /                     QNAME                     /
      /                                               /
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                     QTYPE                     |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                     QCLASS                    |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      
    • 其中QNAME是可变长度的,QTYPE和QCLASS各占2个字节,16bits
    • QNAME - 域名;以标签(label)方式表示的域名;每个标签的第一个字节表示这个标签的长度,后面跟着与该长度相同的字符,多个标签组成一个域名,标签的最后填充1个字节的0,表示标签结束;举例来说,baidu.com将用如下方式表达(以16进制表示):
      length------length------------length------end 
      03 77 77 77 05 62 61 69 64 75 03 63 6f 6d 00
         w  w  w     b  a  i  d  u     c  o  m
      
    • 要注意的是,这个字段的长度(字节数)可能是奇数,不需要填充以保证4字节或2字节对齐;
    • QTYPE - 2字节,表示查询类型;在前面介绍RR时曾介绍了RR中的TYPE字段,所有TYPE的值这里都使用,本文用到的有:QTYPE=1(A记录),QTYPE=5(CNAME记录)
    • QCLASS - 2字节,表示网络类别,常用的值只有1个,即CALSS=1(Internet - 表示互联网)
  • Answer的格式

    • Answer、Authority、Additional的格式是一样的,就是前面介绍的RR的格式,只是这里可能放着多个RR,其具体数量由Header中的ANCOUNT、AUCOUNT和ARCOUNT确定;
    • 我们把RR的的格式在这里再展示一下:
                                      1  1  1  1  1  1
        0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                                               |
      /                                               /
      /                      NAME                     /
      |                                               |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                      TYPE                     |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                     CLASS                     |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                      TTL                      |
      |                                               |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      |                   RDLENGTH                    |
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
      /                     RDATA                     /
      /                                               /
      +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
      
    • 要说明的是,为了减小报文的长度,其NAME部分常常采用一种压缩方案(Compression Scheme);因为在服务器发回的应答报文中,会包含查询请求报文中的QUESTION字段,这个字段中已经存放了域名,如果在ANSWER中再存放一次域名,显然是重复的,所以通常采用压缩方案;
    • 比如我们查询 baidu.com 的A记录,在发出DNS请求的报文中,会包含 baidu.com 这个域名,在应答报文中,会把请求报文中的 question 部分复制过来,那么在 answer 部分的 RR 中的 baidu.com 与 question 中的 baidu.com 就是重复的,这时候,在 anwser 部分的 baidu.com 就会采用压缩方案存储;
    • 采用标签方式存储域名时,第一个字符用于表示这个标签的长度,DNS协议规定,当这个长度字节的最高两位为0时,后面的6位表示这个标签的长度,当这个字节的最高两位全为1时,这个字节的后面6位连同下一个字节一起表示一个从报文首字节开始的偏移,指向一个域名;
    • 前面说过,一个域名的总长度最大为255个字符,以'.'分开的各个部分,每部分的最大长度为63个字符,为什么会限制为63个字符呢?因为在DNS协议里规定只有6 bit用于表示域名每部分的长度,所以每部分的长度最大只能是 26 - 1 = 63
    • 我们用一个实际的例子来说明,这个例子是查询域名 baidu.com 的 A 记录时,实际返回的数据:
      0000:   0xf1  0x01  0x81  0x80  0x00  0x01  0x00  0x02  
      0008:   0x00  0x00  0x00  0x00  0x05  0x62  0x61  0x69  
      0010:   0x64  0x75  0x03  0x63  0x6f  0x6d  0x00  0x00  
      0018:   0x01  0x00  0x01  0xc0  0x0c  0x00  0x01  0x00  
      0020:   0x01  0x00  0x00  0x01  0x72  0x00  0x04  0x6e  
      0028:   0xf2  0x44  0x42  0xc0  0x0c  0x00  0x01  0x00  
      0030:   0x01  0x00  0x00  0x01  0x72  0x00  0x04  0x27  
      0038:   0x9c  0x42  0x0a
      
    • 最左边的一列是在内存中的偏移地址,在 question 部分的域名 baidu.com 出现在偏移地址为 0x000c 的位置,我们把它单独拿出来看:
      000c:   0x05  0x62  0x61  0x69  0x64  0x75  0x03  0x63  0x6f  0x6d  0x00
      
    • 这样可能还不直观,我们换一种更加直观的方式:
      000c:   0x05  'b'  'a'  'i'  'd'  'u'  0x03  'c'  'o'  'm'  0x00
      
    • 这是一种标准的标签方式记录的域名;
    • 这个回应报文中的 answer 有两个,第一个从偏移地址 0x001b 开始,answer 的开始是 NAME 字段,采用了压缩方案存储:
      001b:   0xc0  0x0c
      
    • 按照标签方式,第一个字节应该表示这个标签的长度,但是这个字节 0xc0 的最高两位全为1,所以这里采用的是压缩方案,这个字节的后6位与下个字节一起组成一个偏移,下一个字节是 0x0c,所以偏移应该是:((0xc0 & 0x3f) << 8) + 0x0c = 0x000c,偏移地址 0x000c 处显示的是什么呢?正是 question 部分用标签方式存储的域名 baidu.com
    • 这个例子只是为了直观的说明阳索方案是如何表达一个域名的,实际应用中可能比这个复杂一些,比如可能前面使用标签方式,但最后是一个压缩方案的指针等。

3. 实现一个DNS客户端

  • 我们要实现的这个DNS客户端,仅实现域名 A 记录和 CNAME 记录的查询,这也是最常见的两种DNS记录;
  • 通常 DNS 客户端使用 UDP 实现,DNS 协议规定的端口号是 53;
  • 实现一个DNS客户端的步骤:

    1. 建立一个UDP socket
    2. 设置socket接收超时,避免在接收应答消息时阻塞
       struct timeval timeout;
       timeout.tv_sec  = 5;
       timeout.tv_usec = 0;
       setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
      
    3. 构建DNS request报文

      • DNS request报文由(报头 + 域名 + QTYPE + QCLASS)组成,报头的长度为12字节,QTYPE和QCLASS的长度均为2字节,域名为不定长度
      • 一个域名在 request 中占用的字节数为(域名字符串长度 + 2);域名中每个'.'的位置要换成标签的长度,域名的第一个标签需要增加1字节的长度字节,域名的结束需要填充一个0;
      • 通过以上计算可以得出一个request的长度,合理地分配内存空间
        int dns_name_len = strlen(domain_name) + 2;
        int dns_request_len = 12 + dns_name_len + 2 + 2;
        unsigned char *dns_request = malloc(dns_request_len);
        memset(dns_request, 0, dns_request_len);
        
      • 将一个域名用标签方式存储,这是构建request最复杂的部分,struct dnshdr 定义了DNS报头结构,详见源程序
        uint8_t *dns_name = dns_request + sizeof(struct dnshdr);
        char *p = (char *)dns_name;
        strcpy(p + 1, domain_name);
        char *pdot;
        while ((pdot = index(p + 1, '.')) != NULL) {
            *pdot = 0;
            *p = strlen(p + 1);
            p = pdot;
        }
        *p = strlen(p + 1);
        
      • 填写request中的QTYPE和QCLASS

        uint16_t *qtype = (uint16_t *)(dns_name + dns_name_len);
        uint16_t *qclass = (uint16_t *)(dns_name + dns_name_len + 2);
        
        *qtype = htons(1);
        *qclass = htons(1);
        
      • 填写DNS报头,ID填任何数字都可以,flags中RD=1表示要求服务器进行递归查询,其他字段均为0,qu_count=1表示有一个查询,要注意的是,request中的存储都必须是网络字节序,所以要使用htons()转换一下

        struct dnshdr {
            uint16_t id;
            uint16_t flags;
            uint16_t qu_count;      // Number of questions
            uint16_t an_count;      // Number of answer rr
            uint16_t au_count;      // Number of authority rr
            uint16_t ad_count;      // Number of additional rr
        };
        
        struct dnshdr *dns_header = (struct dnshdr *)dns_request;
        dns_header->id = htons(0x1234);
        dns_header->flags = htons(0x0100);
        dns_header->qu_count = htons(1);
        dns_header->an_count = 0;
        dns_header->au_count = 0;
        dns_header->ad_count = 0;
        
    4. 向DNS服务器发送DNS request报文

       struct sockaddr_in dns_addr;
       memset(&dns_addr, 0, sizeof(struct sockaddr_in));
       dns_addr.sin_family = AF_INET;
       dns_addr.sin_port = htons(53);
       dns_addr.sin_addr.s_addr = inet_addr("114.114.114.114");
      
       sendto(sockfd, dns_request, dns_request_len, 0, (struct sockaddr *)&dns_addr, sizeof(struct sockaddr));
      
    5. 接收来自DNS服务器的应答报文

       uint8_t *buf = malloc(512);
       memset(buf, 0, 512);
       struct sockaddr_in addr;
       unsigned int addr_len = sizeof(struct sockaddr_in);
       recvfrom(sockfd, buf, 512, 0, (struct sockaddr *)&addr, &addr_len);
      
      • DNS协议规定,DNS数据包的长度不超过512字节,所以这里仅给接收缓冲区分配512个字节
    6. 解析DNS服务器的应答报文

      • 从DNS报头获取answer部分的RR数量
        struct dnshdr *dns_hdr = (struct dnshdr *)buf;
        uint16_t ancount = ntohs(dns_hdr->an_count);
        
      • 找到answer部分的起始位置,在应答报文中,要跳过question部分才是answer部分,但question部分不是固定长度的,所以要费点周折;
        uint8_t *p_question = buf + sizeof(struct dnshdr);
        uint8_t *p = p_question;
        while (*p > 0) {
            p += *p;
            p++;
        }
        p++;
        uint8_t *p_answer = p + 2 + 2;
        
      • p_question指向question部分的开头,经过一个循环找到QTYPE字段的位置,再加上QTYPE和QCLASS的长度就找到了answer的起始位置;
      • answer中的内容其实就是一个一个的RR,前面我们已经从包头中获得了RR的数量,这里循环就好了
      • 前面介绍过RR的结构,一个RR是由(NAME + TYPE + CLASS + TTL + RDLENGTH + RDATA)组成,其中CLASS永远为IN,TTL是设置DNS cache时用的,这两项我们可以不用管;
      • TYPE决定着RDATA的格式和内容,我们只解析A记录和CNAME记录,所以如果TYPE为其他类型,我们可以放弃
      • 如果是A记录(TYPE=1),则RDATA中是一个32位的IP地址,占4个字节,NAME中存放着其主域名(不是别名);
      • 如果是CNAME记录(TYPE=5),则NAME中存放的是一个别名,RDATA中存放着这个别名的主域名,此时RDATA中的数据采用标签方式或压缩方案存储域名;
      • 在源程序中有一个函数parse_name()专门用于将RR中的name或者RDATA中的name转换成我们可以读懂的域名格式

        char *owner_name;
        char *cname;
        int name_len = 0;
        uint16_t ans_type;
        uint16_t rdlength;
        uint8_t *p_rdata;
        
        owner_name = malloc(256);
        int i;
        for (i = 0; i < ancount; ++i) {
            memset(owner_name, 0, 256);
            name_len = parse_name(buf, p_answer, owner_name, 256);
            ans_type = ntohs(*(int16_t *)(p_answer + name_len));
            rdlength = ntohs(*(int16_t *)(p_answer + name_len + 2 + 2 + 4));
        
            p_rdata = p_answer + name_len + 2 + 2 + 4 + 2;
            if (ans_type == TYPE_HOST) {
                // host ip in rdata. ip points to rdata.
                printf("The owner name: %s\n", owner_name);
                printf("ip: %d.%d.%d.%d\n", p_rdata[0], p_rdata[1], p_rdata[2], p_rdata[3]);
            } else if (ans_type == TYPE_CNAME) {
                // canonical name in rdata
                cname = malloc(256);
                memset(cname, 0, 256);
                parse_name(buf, p_rdata, cname, 256);
                printf("The alias name: %s\n", owner_name);
                printf("The canonical name: %s\n", cname);
                free(cname);
            }
            // point to next answer
            p_answer = p_answer + name_len + QTYPE_LEN + QCLASS_LEN + TTL_LEN + RDLENGTH_LEN + rdlength;
        }
        free(owner_name);
        
      • owner_name是RR中NAME字段中的域名,cname是当TYPE=CNAME时,RDATA中的域名
      • parse_name()是解析name的函数,其返回值为该name在报文中占用的字节数,p_answer是指向answer部分开头的指针,所以p_answer加上parse_name()的返回值就是RR中TYPE字段的位置,再加上TYPE、CLASS和TTL的长度,就是RDLENGTH字段的位置,再加上RDLENGTH的长度,就是RDATA的位置,p_data指针就是这样得到的;
  • 完整源程序的文件名为:dns-client.c(点击文件名下载源程序)

  • 编译:gcc -Wall dns-client.c -o dns-client
  • 运行:./dns-client baidu.com

    • 查询这个域名通常会返回2条A记录

      Screenshot of baidu.com

  • 运行:./dns-test www.baidu.com

    • baidu.com其实是一个别名,所以这个查询会得到一个CNAME记录和2个A记录

      Screenshot of www.baidu.com

4. 后记

  • 在日常的网络活动中,有时会遇到DNS污染的困扰,比如在浏览器中输入域名,却无法到达我们期望到达的网站,这有时就是因为我们收到的DNS回应并不是来自一个合法的DNS服务器;当遇到此类问题时,本文的程序可以帮助你判断是否存在DNS污染;
  • 如果一个网站没有完成备案,则使用域名访问时也无法到达该网站,这其实也是在DNS上做的文章;
  • 本文仅对DNS的A记录和CNAME记录做了解析,其实常用DNS记录还有MX、TXT等;
  • DNS的反向查询指的是使用IP地址查询其对应的域名;
  • DNS协议的在rfc 1035中定义的反向查询(Inverse Queries),但是该功能是可选的(Optional),也就是说DNS服务器可以不具备反向查询的功能,所以使用这个功能可能无法达到预期结果;
  • 但是DNS协议在rfc 1035中定义了一个域 IN-ADDR.ARPA,利用这个域的查询可以达到和反向查询类似的功能,所以在实际应用中,如果需要做DNS反向查询,通常都是采用 IN-ADDR.PARA 域来完成,更详细的信息请查阅 rfc 1035 第3.5节。

欢迎访问我的博客:whowin.cn

email:

donation