5

实际应用中发现一个问题,在某些国家/ 地区的某些 ISP 提供的网络中,程序在请求 DNS 以连接一些服务器的时候,有时候会因为 ISP 的 DNS 递归查询太慢,导致设备端认为 DNS 超时了,无法获取服务器 IP。

给用户的解决方案是:请不要用 ISP 自动分配的 DNS server,改用 8.8.8.8 就解决了。

但是让用户这么配置太麻烦、也太不友好了。于是我就思考:能不能自己实现 DNS 服务,当 ISP 的 DNS 请求超时或者失败的时候,就从内部直接向 8.8.8.8 请求 DNS 信息,可以不?

如果要使用 gethostbyname()getaddrinfo() 来解决这个问题的话,方案是将 /etc/resolve.conf 修改了。但这并不是正确的办法,因为这种改法一来不准确,二来会影响系统其他 DNS 请求。可行的方案是:自己构建 DNS 请求,并且自己解析获得我们需要的 IP 信息。

本文地址:https://segmentfault.com/a/1190000009369381

Reference

DNS 这样一个在网络互联中算是一个比较简单的协议,实现我如此简单的需求,居然没有哪个参考资料能够覆盖我需要的知识点……

我自己也进行了抓包,抓包的时候,建议不直接向权威的 DNS server 发送请求,而是向网关、路由器等提供 DNS 中继的服务器发,这样可以获得比下面最后一个参考资料更多的信息。

《用 TCP / IP 进行网际互联(第五版)——原理、协议与结构(第五版)》,Douglas E. Comer
《计算机网络(第5版)》,Andrew S. Tannenbaum, David J. Wetherall:男神塔能鲍姆教授!
DNS Protocol
DNS Reference Information:有各种 type 的说明
Domain Name System (DNS) Parameters:有各种参数的总集合
DNS Name Notation and Message Compression Technique
RFC-1035
对 DNS 报文的理解
DNS message解析:这篇文章也挺仔细地说明了 DNS 报文结构,图形控可以看
利用 WireShark 进行 DNS 协议分析

DNS 基本概念

简要整理一些和本文相关的点:

DNS 的本质是发明了一种层次的、基于域的命名方案,并且用一个分布式数据库系统加以实现。DNS 的主要作用是将主机名映射成 IP 地址。

DNS 解析的发起端一般是互联网 Server / Client 模型中的 client 端(以下称 client 端,指的就是发起 DNS 解析的一端),现在大部分的 C 语言 client 端都使用 getaddrinfo() 实现。以前一般用 gethostbyname() 因为一些原因不再推荐使用了,并且也只支持 IPv4。

DNS 解析中,DNS server 开放的端口应当是 53 端口。当 client 端作出请求时,server 返回的不仅仅是 IP 信息,还包含于该域名相关联的资源记录。

仅仅从一个域名 URL 中,我们不能区分这是一个域名还是某个对象(主机)名。域名的总长度应小于等于 255 个字节,域名的每一段则必须小于等于 63 字节

DNS 报文格式

DNS 请求的格式和响应格式差不多,就不单独讲了。从 UDP 数据包的正文部分算起,DNS 报文的结构按顺序如下:

数据类型 Ethereal 里的名字 说明
uint16_t Transaction ID 标识符。下文说明
uint16_t Flags 参数。下文说明
uint16_t Questions 询问列表的数目
uint16_t Answer RRs (直接) 的回答数
uint16_t Authority RRs 认证机构数目(仅响应包里有)
uint16_t Additional RRs 附加信息数目(仅响应包里有)
variable Queries 请求数据的正文。请求包中只有这个。响应包也会附上原本的请求数据
variable Answers 响应数据的正文
variable Authortative name servers 域名管理机构数据
variable Additional records 附加信息数据
  • Transaction ID:这是由 client 端指定的标识数据,DNS server 会将这个字段原样返回,client 端可以用来区分不同的 DNS 请求
  • RRResource Record 的缩写

Flags

16 bits 的值,各部分按顺序如下(按顺序:位号、Ethereal 名称、说明):

  • Bit 15,Response:0 表示查询,1 表示响应(query / response)
  • Bit 14~11, Opcode:查询类型——请求和响应包都适用:

    • 0:普通查询(最常用的)
    • 1:反向查询
    • 2:服务器状态请求
    • 3:通知
    • 4:更新(貌似是用在 DDNS 的?)
  • Bit 10, Authoritative:用于响应包,判断服务器是否一个认证的域服务器
  • Bit 9, Truncated:报文是否被截断了。收发包都用
  • Bit 8, Recursion desired:收发包都用,表示是否需要用递归。作为 client 端,最好置 1,要不然 DNS 不执行递归查询,将有很多数据没能查到
  • Bit 7, Recursion available:响应包用,表示服务器是否有能力使用递归查询
  • Bit 6:这个数据段,Ethereal 说是保留位,而书中表示数据是否是鉴别的——求确认
  • Bit 5, Answer authenticated:数据是否被服务器鉴定过(貌似抓到的包里都是 0)
  • Bit 4, Reserved
  • Bit 3~0, Reply code:响应状态码,如下(参见 Micrisoft 资料 的 “DNS update message flags field” 小节):

    • 0:OK
    • 1:查询格式错误
    • 2:服务器内部错误
    • 3:名字不存在
    • 4:这个错误码不支持
    • 5:请求被拒绝
    • 6:name 在不应当出现时出现(什么鬼)
    • 7:RR 设置不存在
    • 8:RR 设置应当存在但是却不存在(什么鬼)
    • 9:服务器不具备改管理区的权限
    • 10:name 不在管理区中

资源记录(RR)的格式

每一条 RR 的格式如下:

数据类型 Ethereal 里的名字 说明
variable Name 资源的域名——其实前文已经出现了
uint16_t Type 类型。下文说明
uint16_t Class 大多数是 0x0001,代表 IN
uint32_t Time to Live TTL 秒数
uint16_t Data length 当前 RR 剩余部分的长度
variable RR 主数据

如果是请求数据的话,那么 TTL、Data Length 和 RR 主数据都不需要

Type 的大部分值在 RFC-1035 中定义,此外的一些在其他文档定义(比如 IPv6)。我会用到的有:

  • 1:“A”,表示 IPv4 地址
  • 2:“NS”,域名服务器的名字
  • 28:“AAAA”,表示 IPv6 地址
  • 5:“CNAME”,规范名,经常会有一个 CNAME 跟着一票 A 和 AAAA

域名压缩显示

这一部分直接参考的是 RFC-1035 的 “4.1.4. Message Compression”小节。

RR 中的 Name 字段,有三种表示方法(不是官方分类,而是本人自己分的):

完整域名表示

比如表示 “www.google.com” 这样一个完整的域名,需要以下16个字节:

B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 B13 B14 B15
\3 w w w \6 g o o g l e \3 c o m \0

注意这里并不是把谷歌的 URL 使用简单的 char * 字符串复制上去,而是将每一段都分割开来。本例子中将域名分成了三段,分别是 www, google, com。每一段开头都会有一个字节,表示后面跟着的那段域名的字节长度。最后当读到 \0 的时候,表示不再有数据了(这里和 char *\0 含义有一点不同,虽然形式上是一样的)

标号表示

前文我们提到,域名的每一段,最长不能超过 63 个字节,因此在表示域名段长度的这个字节的最高两0xC0),必然是 0。这就引申出了这里的第二种用法。

这种表示法中,相当于一个指针,指代 DNS 报文中的某一个域名段。在解析一段 RR 数据段时,需要判断域长度嘛,判断的逻辑是:

  • 如果最高两位是 00,则表示上面第一种
  • 如果最高两位是 11,则表示这是一个压缩表示法。这一个字节去掉最高两位后剩下的6位,以及接下来的 8 位总共 14 位长的数据,指向 DNS 数据报文中的某一段域名(不一定是完整域名,参见第三种),可以算是指针吧。

比如 0xC150,表示从 DNS 正文(UDP payload)的 offset = 0x0150 处所表示的域名。0x0150 是将 0xC150 最高两位清零得到的数字。

混合表示

这就是上面两种的混合表示。比如说,我们假设前文表示 www.google.com 的完整域名的数据段处于 DNS 报文偏移 0x20 处,那么有以下几种可能的用法:

  • 0xC020:自然就表示 www.google.com
  • 0xC024:从完整域名的第二段开始,指代 google.com
  • 0x016DC024:其中 0x6d 就是字符 m,因而 0x016D单独指代字符串 m;而第二段 0xC024 则指代 google.com,因此整段表示 m.google.com

分析工具

除了 Ethereal 之外,推荐的分析工具有:

代码实现

代码实现在我用来研究 epoll() 的分支中,GitHub 工程在此,许可证为 LGPL。
实现逻辑上其实还是挺简单的,照着上面提到的原理实现就好了。大部分的代码和本文无关,只需要看里面的 AMCDns.c / h 文件即可。

我的这些代码可以完全代替阻塞的 getaddrinfo() 函数,甚至也可以集成到异步 I/O 库中。使用流程如下:

  1. 调用 socket() 创建一个 UDP 套接字并 bind()
  2. 调用 AMCDns_GetDefaultServer() 获取系统默认配置的 DNS 服务器
  3. 如果不使用系统默认的 DNS 服务器,则需要使用 struct addrinfo 类型来指定。
  4. 调用 AMCDns_SendRequest() 请求指定域名的 IP 信息
  5. 调用 AMCDns_RecvAndResolve() 获取摘要的或完整的响应。
  6. 调用 AMCDns_FreeResult() 清除 DNS 响应数据以避免内存泄露
  7. close() 掉 socket

amc
927 声望228 粉丝

电子和互联网深耕多年,拥有丰富的嵌入式和服务器开发经验。现负责腾讯心悦俱乐部后台开发