文章目录
  1. 1. 文档更新说明
  2. 2. 前言
  3. 3. 网络协议
    1. 3.1. DNS协议报文格式
      1. 3.1.1. 解析 Question/Answer/Authority/Additional
      2. 3.1.2. 大小端问题
    2. 3.2. 实现DNS代理服务的第一步, 解析数据
    3. 3.3. 第二步, 调整DNS报文
    4. 3.4. 第三步, 修改DNS报文的目标服务器
    5. 3.5. 第四步, 调整响应报文返回给请求方
  4. 4. 推荐阅读

文档更新说明

  • 最后更新 2020年08月15日
  • 首次更新 2020年08月16日

前言

最近负责了一个DNS相关的网络模块开发, 做了一两个星期算是做完了, 总结一下遇到的一些经历, 功能就是实现DNS代理服务, 具体细节下文会说.

本文只设定在UDP下的DNS报文, 因为我发现Mac上的mDNSResponder进程他发的就是UDP的. 不过要记得, DNS报文实际上可以放到TCP上做.

DNS协议, 这个应该是大部分APP都会涉及到的协议, 简单点说就是域名到IP地址的解析, 一般会用系统自带的DNS服务解析域名, 也可能会自己定制类似DNS解析的功能, 比如常见的有HTTPDNS, 这个中大型APP都会用到的, 目的就是丰富DNS协议, 自己搭建解析服务器, 请求的时候可以带上一些APP的版本, 地理位置, 网络状态等数据, 然后根据自己公司的服务器配置特点, 返回用户访问延迟最低最合适的IP地址.

网络协议

一听到网络协议, 咋一看, 可能感觉很高端, 离自己很远, 其实不然. DNS协议, 就是一个建立在UDP传输层的一套简单的不可靠协议.

一个DNS报文, 要完成的工作无非就是带上域名, 请求解析服务器, 得到域名的IP地址, 而如果请求丢包, 只需要重新请求即可, 任务基本一次发送, 一个来回就可以完成, 所以DNS协议是比较简单的.

打开终端输入dig www.google.com, 大概就可以看到下面这样, 这个工具的输出非常简洁.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
; <<>> DiG 9.10.6 <<>> www.google.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 4593
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 4, ADDITIONAL: 3

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;www.google.com. IN A

;; ANSWER SECTION:
www.google.com. 72 IN A 64.13.232.149

;; AUTHORITY SECTION:
google.com. 71471 IN NS ns1.google.com.
google.com. 71471 IN NS ns3.google.com.
google.com. 71471 IN NS ns4.google.com.
google.com. 71471 IN NS ns2.google.com.

;; ADDITIONAL SECTION:
ns1.google.com. 272938 IN A 216.239.32.10
ns4.google.com. 267495 IN A 216.239.38.10

;; Query time: 9 msec
;; SERVER: 10.10.1.130#53(10.10.1.130)
;; WHEN: Fri Aug 14 11:41:32 CST 2020
;; MSG SIZE rcvd: 163

DNS协议报文格式

所谓的网络协议, 其实就是端对端约定的数据格式, 大家都知道格式, 才能正确传递信息. DNS协议也是这样, 下面贴一下DNS协议报文格式.

1
2
3
4
5
6
7
8
9
10
11
+--+--+--+--+--+--+--+
| Header |
+--+--+--+--+--+--+--+
| Question |
+--+--+--+--+--+--+--+
| Answer |
+--+--+--+--+--+--+--+
| Authority |
+--+--+--+--+--+--+--+
| Additional |
+--+--+--+--+--+--+--+

上图, 一共分成5个部分, 各个部分详细字节如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Header format

0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ID |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| opcode |AA|TC|RD|RA| Z | RCODE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QDCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ANCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NSCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ARCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

上面是头部, 一共12个字节, 接收DNS报文的时候, 如果一次读取到的数据小于12个字节的话, 说明报文格式错误, 直接丢弃.

1
2
3
4
5
6
7
8
9
10
11
rwBuff := make([]byte, 1024)
n, err := conn.Read(rwBuff)
if err != nil {
// 报错
return
}

if n < 12 {
// 丢弃
return
}

对于大于12字节的报文, 根据头部QR这个bit的值, 可以知道当前报文是查询DNS(0, query)还是响应(1, respond), 其他位的含义, 比如opcode等, 有兴趣可以看文末推荐阅读DNS报文详解.

从第5个字节开始, 往后8个字节, 分别用作四类Section的计数, 每个Section计数占2个字节, 可以用uint16保存. 这里需要说一下四类Section分别用来做什么.

QDCOUNT, 表示question section记录数量, 该section存放问题信息, 常见的比如一个域名, 期望解析获取IP地址.

ANCOUNT, 表示answer section记录数量, 该section存放应答信息, 常见的比如域名对应的IP地址.

NSCOUNT, 表示authority section记录数量, 该section存放应答信息, 比如权威服务器域名.

ARCOUNT, 表示additional section记录数量, 该section存放应答信息, 比如权威服务器对应的IP地址.

解析 Question/Answer/Authority/Additional

四种section一共只有2种数据格式, 其中question独立一种, 另外3种section格式都是一样.

1
2
3
4
5
6
7
8
9
10
11
12
13
Question format

0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ... |
| QNAME |
| ... |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QTYPE(uint16) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QCLASS(uint16) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

QNAME, 表示域名, 此时QTYPE(uint16, 下文默认2字节的整型, 都是uint16)的值是1, QCLASS通常都是1, 表示Internet, 下文默认QCLASS都是1.

QNAME的格式有点复杂, 首字节由一个不大于63的数表示长度, 后面接着ascii字符, 接着继续一个不大于63的数, 后面继续表示字符, 直到遇到0结束.

看一下DNS解析域名时, QNAME的格式

1
2
3
4
5
6
7
域名www.google.com, 在QNAME种是这样存放
3 119 119 119 6 103 111 111 103 108 101 3 99 111 109 0
其中3,6,3分别是计数, 把域名分成3部分
119 119 119
103 111 111 103 108 101
99 111 109
翻译成字符, 在每段的间隔都加一个".", 得到的就是 www.google.com

再看IP反响解析成域名时, QNAME的存放格式

1
2
3
4
5
6
7
8
64.13.232.149 这个反响查询IP地址, 在QNAME中的是这样存放的
2 54 52 2 49 51 3 50 51 50 3 49 52 57 0
其中2,2,3,3这四个分别用来记录长度, 后面加上该段的长度, 所以IP地址一共4段, 分别是
54 52
49 51
50 51 50
49 52 57
这些都是表示ascii码符号, 翻译过来, 每段的间隔都加一个".", 得到的就是64.13.232.149

QNAME还有一种情况, 就是域名存放重复字符时, 会有一个压缩算法, 具体压缩就不深入讨论了.

解决了QNAME的解析, 剩下的4个字节, 分别是QTYPE和QCLASS, 其中QTYPE表示当前QANNE的类型, A是域名(值1), PTR是反响查询的IP地址(值12), 类型对应的值可以查看这里DNS报文类型值, 这个是golang写的解包库, 后面也会用到, 很齐全.

Question和下面要说的另外三种section中的类型, 大部分是共享的.

1
2
3
4
5
常见的如下:
A类型(值1), 表示IP地址;
NS类型(值2), 表示权威服务器;
CHAME类型(值5), 表示域名的别名;
MX类型(值15), 表示邮件服务器域名;

下面再看一下Answer类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Answer/Authority/Additional format

0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NAME |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TYPE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| CLASS |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| TTL(uint32) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDLENGTH(uint16) |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| RDATA |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

其中NAME和QNAME的格式一样, 解析方法一样.
TYPE和CLASS和QTYPE,QCLASS的类型集合基本一致, 类型代表的意义也一样.
TTL代表缓存时长.
RDLENGTH代表数据部分的长度.
RDATA就是数据了. 当TYPE=A, CLASS=IN, 那么RDATA就是4个字节的IP地址.

其他几种section格式一样, 解析方法一样, 就不多说了.

大小端问题

网络数据用的都是大端模式, 所以解析多字节类型时, 比如上面的QDCOUNT, 2个字节的数据转换成uint16位时, 需要把字节序转换成本地序

还是简单复习一下大端模式把, 这块知识我看了无数次都记不住, 最后终于找到一个方法来记它. 所谓的大端模式, 就是把一个类型的低位放到高字节上去. 记住这一点就行, 记多了会乱.

例如ANCOUNT两个字节的内容为 ancount[0x12, 0x34]; 低字节是12, 高字节是34, 解析的时候, 就得把高字节的数字当作uint16的低位处理, 低字节的数字当作uint16的高位处理;

所以把2个字节转成uint16类型的代码如下

1
2
count = ancount[1] + ancount[0] << 8
//binary.BigEndian.Uint16(ancount)

其他的比如IP地址4个字节, 如果转成uint32的话, 也是一样的方法

1
2
addr = ip[3] + ip[2] << 8 + ip[1] << 16 + ip[0] << 24
//binary.BigEndian.Uint32(ancount)

实现DNS代理服务的第一步, 解析数据

要代理DNS请求, 第一件要做的事情肯定是解析协议. 这块知道怎么解析就行了, 别手动解析, 每种编程语言都有很多开源库用来解包DNS报文, 这些都是经过测试的, 比较稳定, 肯定比自行解析协议要靠谱.

这里我用的golang的开源库https://github.com/miekg/dns, 代码挺清晰的, 简单易懂. 里面封装好了监听UPD处理数据的逻辑, 也封装了发起DNS请求的逻辑, 具体可以看文档, 这里我都不需要这些封装, 只需要他的解包和把结构体转成原始报文这两个功能即可.

简单看一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import "github.com/miekg/dns"

var lc net.ListenConfig
b := make([]byte, 1024)
conn, err := lc.ListenPacket(context.Background(), "udp", ":8053")
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
udpConn := conn.(*net.UDPConn)

n, addr, err := udpConn.ReadFromUDP(b)
if err != nil {
fmt.Println(err)
}
if n < 12 {
return
}

msg := dns.Msg{}
msg.Unpack(b)

这样就可以把dns报文解包后放到dns.Msg这个结构体上了, 非常方便.

第二步, 调整DNS报文

这部分, 主要是根据自己的业务逻辑, 决定是否修改DNS的报文信息, 之后再用修改过的报文重新请求下一级服务器.

第三步, 修改DNS报文的目标服务器

这里DNS报文的目标服务器IP, 其实是在UDP报头四元组上, 这里直接自己定义下一级DNS服务器IP地址, 建立一个socket, 就可以把报文转发出去了.

1
2
3
4
5
6
7
8
9
10
11
12
13
var d net.Dialer
d = net.Dialer{Timeout: time.Second * 3}
conn2, err := d.Dial("udp", "10.10.1.130:53")
if err != nil {
fmt.Println(err)
}
defer conn2.Close()

conn2.Write(b)
conn2.Read(b)

// 解包得到响应报文
msg.Unpack(b)

代码挺简单的, 这样就可以直接把报文转发到另一台服务器了. 然后也可以得到响应的报文并解包. 当然实际开发场景肯定比这个复杂, 不过代理服务器就这个原理. 这里如果DNS报文是加密的解没法这么处理了. 加密的暂时没去搞,就不写了.

这部分的意义, 其实可以理解为根据用户的一些特征, 选择最合理的解析服务器, 实际有一种服务叫HTTPDNS, 就是为了取代DNS解析服务, 自行择优解析.

第四步, 调整响应报文返回给请求方

最后还得把报文写回给请求方, 写回去之前可以任意修改, 只要符合自己的需求场景就行.

1
2
3
4
5
6
// 对msg做一些修改, 之后写回给请求方
//TODO: modify msg

// 重新打包成字节数组后, 写回给请求方
b, err = msg.Pack()
udpConn.WriteToUDP(b, addr)

不过有一点要注意, 请求报文中的QNAME, 必须和Answer里面的CHAME类型的NAME部分保持一致, 因为这个是声明了域名的别名, DNS响应报文一般都会包含别名的IP记录. 比如www.baidu.com, 解析结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
; <<>> DiG 9.10.6 <<>> www.baidu.com @127.0.0.1 -p 8053 -t A
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 35122
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 5, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;www.baidu.com. IN A

;; ANSWER SECTION:
www.baidu.com. 1114 IN CNAME www.a.shifen.com.
www.a.shifen.com. 200 IN A 14.215.177.38
www.a.shifen.com. 200 IN A 14.215.177.39

;; AUTHORITY SECTION:
a.shifen.com. 502 IN NS ns4.a.shifen.com.
a.shifen.com. 502 IN NS ns3.a.shifen.com.
a.shifen.com. 502 IN NS ns2.a.shifen.com.
a.shifen.com. 502 IN NS ns1.a.shifen.com.
a.shifen.com. 502 IN NS ns5.a.shifen.com.

;; Query time: 10 msec
;; SERVER: 127.0.0.1#8053(127.0.0.1)
;; WHEN: Fri Aug 14 17:12:47 CST 2020
;; MSG SIZE rcvd: 359

其中ANSWER包含了别名shifen, 还有别名的IP地址.

我一开始就是因为对报文做了一些调整后, 没有让QNAMEANSWERCHAME保持一致, 导致请求方一直无法正确解析数据, 调试了好长时间.

推荐阅读

DNS报文详解
DNS报文类型值
DNS解包库
大小端

文章目录
  1. 1. 文档更新说明
  2. 2. 前言
  3. 3. 网络协议
    1. 3.1. DNS协议报文格式
      1. 3.1.1. 解析 Question/Answer/Authority/Additional
      2. 3.1.2. 大小端问题
    2. 3.2. 实现DNS代理服务的第一步, 解析数据
    3. 3.3. 第二步, 调整DNS报文
    4. 3.4. 第三步, 修改DNS报文的目标服务器
    5. 3.5. 第四步, 调整响应报文返回给请求方
  4. 4. 推荐阅读