概要

dns 是一个基于 Go 的 DNS 库,支持 DNS 的所有特性,严格遵循 Less is more 的原则,被广泛用在很多著名的开源项目中,比如 CoreDNS(作者同时也是 CoreDNS 的核心开发者)。

这个库不仅仅可以用于客户端的 DNS 查询,而且也可用于服务端 nameserver 的编程(比如 CoreDNS)。Go 标准库客户端的 DNS 查询功能比较单一(比如 net.Lookup* 系列),缺少更细粒度的控制,如有特殊的需要,我们也可以用这个库构建符合自身服务特点的 DNS 查询。

这个库非常容易上手,在开玩之前我们先来复习一下 DNS 的基础知识。

DNS 基础知识复习

备注:大部分内容来源于《TCP/IP 详解》

DNS 报文格式

DNS 的查询和响应都遵循如下格式:

这个报文由 12 bytes 的首部4 个长度可变的字段组成。

  • 标识:由 client 设置并由 server 返回结果,用以确定响应与查询是否匹配

  • 标志:16 位的标志字段被划分为若干子字段,如下图所示:

    • QR:1 bit,0 表示查询报文,1 表示响应报文;

    • opcode:4 bit,通常值为 0(标准查询),其他值为 1(反向查询)和 2(服务器状态请求);

    • AA:1 bit,表示授权回答(authoritative answer),该名字服务器是授权于该域;

    • TC:1 bit,表示可截断(truncated)。使用 UDP 时,表示当应答总长度超过 512 bytes 时,只返回 512 bytes;

    • RD:1 bit,表示期望递归(recursion desired)。该比特能在一个查询中设置,并在响应中返回。该标志告诉名字服务器必须处理这个查询,即这是一个递归查询(说白了就是,我把请求交给你,不管你最终找谁处理,最后必须将响应回复给我)。如果设置为 0,说明这是一个迭代查询(我把请求给你,如果当前名字服务器无法处理,返回谁可以处理的名单),当请求的名字服务器没有一个授权回答,它就返回一个能解答该类型的其他名字服务器的列表;

    • RA:1 bit,表示可用递归。如果名字服务器支持递归查询,则在响应中将该位设置为 1;

    • 随后的 3 bit 必须为 0;

    • rcode:4 bit 的返回码,通常为 0(没有差错)和 3(名字差错)。名字差错只有从一个授权名字服务器上返回,表示查询中指定的域名不存在。

  • 问题数、资源记录数、授权资源记录数和额外资源记录数分别对应最后 4 个可变长字段中包含的条目数,对于查询报文,问题数通常是 1,而其他 3 项均为 0;对于应答报文,回答数至少为 1,剩下的两项可以是 0 或非 0;

  • 查询问题

    问题部分中每个问题的格式如下所示(通常只有 1 个问题):

    • 查询名:即要查找的 DNS 名字,本质上一个分段带长度的字符序列,每个标识符以首字节的计数值来说明随后标识符的字节长度,每个名字以字节 0 结束。长度为 0 的标识符是根标识符。

      计数字节的值必须是 0~63 的数,因此标识符的最大长度仅为 63。

      该字段无须对齐和填充,如下是域名 gemini.tuc.noao.edu. 的表示:

    • 查询类型:每个问题都有一个查询类型,而每个响应(即每个资源记录)也有一个类型。比如 A 记录的话,该字段为 1;

    • 查询类:通常是 1,指互联网地址(IP);

  • DNS 报文中最后三个字段均采用资源记录 RR(Resource Record)的相同格式,如下所示:

    • 域名:记录中数据对应的名字,格式与前文查询报文中对应字段一致;

    • 类型:与前文中的查询报文中的查询类型一致;

    • :通常是 1,指互联网数据;

    • 生存时间:客户端程序保留该资源记录的秒数,通常为 2 天;

    • 资源记录长度:说明资源记录的数量,该数据的格式依赖于类型字段的值。对于类型 1(A 记录)的资源数据是 4 字节的 IP 地址;

标签压缩

每一个查询请求和 RR 都是由名称开始,名称由一系列标签(label)组成。标签有两种类型:数据标签(data label)和压缩标签(compression label)。数据标签如上文所述,压缩标签则相当于一个指针。

在许多情况下,DNS 响应消息中几个字段中会有重复字符串,为避免这种冗余,采用如下压缩方式:原本计数字节高两位设置为 1,剩余位与随后的一个 byte 组成一个 14 位的指针,即偏移量,给出距离距离 DNS 消息开始出的字节数,在那可找到一个用于替代压缩标签的数据标签,即压缩标签能够指向一个距离开始处多达 16384(2^14)个字节的位置。如下所示:

这表示了两个域名:usc.eduucla.edu,其中 edu 字符串只需要出现一次,并与其他域名共享。

常见资源类型

  • A 记录:一个 A 记录定义了一个 IPv4 地址,存储 32 bit 的二进制数;

  • AAAA 记录:查询 IPv6 地址;

  • CNAME 记录:另一个域名的别名。即解析出来是另外一个域名;

  • SRV 记录:用来标识某台服务器使用了某个服务;

一个查询的例子

如果我们查询 gemini.tuc.noao.edu.,得到的响应如下所示:

查询返回两个 A 记录(即这个域名对应两个 IP)。

注意以下几点:

  • 返回结果中包含查询问题

  • 在返回的结果中会有许多重复的域名,因此使用压缩方式。比如上面的例子域名出现了 3 次,使用标签压缩的方式,则 RR 中原本域名的字段变成指针字段;

实际例子

我们来看一个实际的例子,根据这个例子稍加修改而来:

 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
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
	"log"
	"net"
	"os"

	"github.com/golang/go/src/fmt"
	"github.com/miekg/dns"
)

func main() {
	// 读取当前运行环境的 /etc/resolv.conf,获得 name server 的配置
	config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")

	// 构造发起 DNS 请求的客户端
	c := new(dns.Client)

	// 构造 DNS 报文
	m := new(dns.Msg)

	// 设置问题字段,即查询命令行参数第一个参数的 A 记录
	m.SetQuestion(dns.Fqdn(os.Args[1]), dns.TypeA)
	m.RecursionDesired = true

	// client 发起 DNS 请求,其中 c 为上文创建的 client,m 为构造的 DNS 报文
	// config 为从 /etc/resolv.conf 构造出来的配置
	r, _, err := c.Exchange(m, net.JoinHostPort(config.Servers[0], config.Port))
	if r == nil {
		log.Fatalf("*** error: %s\n", err.Error())
	}

	if r.Rcode != dns.RcodeSuccess {
		log.Fatalf("*** invalid answer name %s after MX query for %s\n", os.Args[1], os.Args[1])
	}

	// 如果 DNS 查询成功
	for _, a := range r.Answer {
		fmt.Printf("%v\n", a)
	}
}

具体的含义可以参考具体注释。

让我们运行一下这个例子:

1
2
3
4
$ go build main.go
$ ./main baidu.com
baidu.com.      465     IN      A       220.181.57.216
baidu.com.      465     IN      A       123.125.115.110

除了这个非常简单的例子之外,作者还在 exdns 列举了更多的例子,大家可以进一步阅读。

祝大家玩得愉快