Pipy 是个可编程代理,曾经我们做过 TCP/HTTP 代理、MQTT 代理、Dubbo 代理、Redis 代理、Thrift 代理。前几天有人问 DNS[1] 的代理能不能做?当然可以,而且 DNS 代理已经应用在 跨集群流量调度 中,文末经对此进行简单地介绍。
阅读本文将了解到:
• DNS 的基本介绍以及 DNS 的处理流程
• 使用编码实现一个 DNS 代理
• 在代理中增加智能线路解析功能
dns procedureDNS(Domain Name System,域名系统)是互联网的一项服务。它将域名和 IP 地址相互映射为一个分布式数据库,能够使人更方便地访问互联网。DNS 使用 TCP 和 UDP 端口 53。
-- 摘自维基百科
简化版的 DNS 处理流程:
1. DNS 客户端(如浏览器、应用程序或者设备)发送域名 example.com
的查询请求。
2. DNS 解析器收到请求,查询本地缓存,如果本地有记录且未过期会返回本地的记录。
3. 如果本地缓存未命中,DNS 解析器将从 DNS 根服务器开始向下查询,首先是顶级域名(Top Level Domain, TLD) DNS 服务器(这里是 .com
),一直向下直到可以解析 example.com
的服务器。
4. 能够解析 example.com
的服务器成为权威 DNS 名称服务器(Authoritative DNS name server),解析器访问该服务器并收到 IP 地址等相关信息,然后返回给给客户端。解析完成。
相信在工作的时候会遇到需要改 DNS 记录来更新域名的真实指向,比如切换运行环境、流量拦截,DNS 也经常作为服务发现的手段之一。通常 DNS 服务器要么是服务提供商业维护,要么就是企业内部的网络团队,导致修改 DNS 的解析记录不够便利。而且由于 DNS 的缓存设计,每条记录都有个 TTL 的设置,在缓存失效前都不会再去更新记录。TTL 过长过短,都不合适。
引入 DNS 代理,可以在解决这个问题的同时,实现更多的功能。
接下来通过案例来演示如何使用 Pipy 实现 DNS 的代理(准确来讲,应该是代理和服务器的合体),这个代理会从自定义的记录中返回 DNS 查询请求。同时我们还会加入特性:根据客户端 IP 的地址返回不同的 DNS 记录,来实现智能线路解析。演示中所使用的脚本,都可以从 这里[2] 下载。
如上图所示,DNS 代理与原来的解析器,提供类似的功能。但是在缓存失效或者未命中时,会查询自定义的解析记录。如果有自定义记录,就返回自定义记录;如果没有,按照原来的流程去 DNS 服务器上查询。
在开始之前,借助 wireshark 的网络抓包来看下 DNS 消息的格式,DNS 查询和应答的消息格式是一样的,都包含一下四个部分:
• 头部:包含了 ID、标记、查询的条目数、应答的条目数、权威资源条目数以及附加资源条目数。
• 标记部分:这部分标识消息类型、名称服务器是否权威、 查询是否递归、请求是否被截断,以及状态。
• 请求部分:包含正在/需要解析的域名和记录类型(A、AAAA、MX、TXT 等)。域名中的每个标签都以其长度为前缀。
• 应答部分:包含查询域名的资源记录。
在 Pipy 0.70.0 的更新 中,假如了 DNS 的解码器。使用 DNS 解码器,可以对 DNS 消息进行解码,解码出上面的四个部分。
实现的脚本逻辑很简单,为了方便阅读将其按功能分成了几个模块,实现了 A
、AAAA
、CNAME
、MX
、TXT
、NS
几个常见类型的记录解析。
├── cache.js #缓存
├── main.js #主入口脚本
├── records.js #自定义记录的逻辑
├── records.json #自定义记录的内容
├── smart-line.js #智能线路解析的逻辑
└── smart-line.json #智能线路解析的配置
这里列出 main.js
[3] 的部分核心代码,并对代码进行了注解:
1. 首先使用 DNS.decode()
对数据流进行解码
2. 然后从结果中找到要查询域名和类型
3. 查询缓存
4. 缓存未命中,查询自定义的记录。
5. 智能线路解析
6. 返回响应
7. 如果 3、4 均查询不到,会请求上游的 DNS 服务器,然后缓存并返回响应
.listen(5300, { protocol: 'udp' })
.replaceMessage(
msg => (
(query, res, record) => (
query = DNS.decode(msg.body), //1
query?.question?.[0]?.name && query?.question?.[0]?.type && ( //2
record = getDNS(query.question[0].type + '#' + query.question[0].name) //3
|| local.query(query.question[0].name, query.question[0].type) //4
),
record ? (
record = line.filter(__inbound.remoteAddress, record), //5
res = {},
res.qr = res.rd = res.ra = res.aa = 1,
res.id = query.id,
res.question = [{
'name': query.question[0].name,
'type': query.question[0].type
}],
record.status === 'deny' ? (
res.rcode = local.code.REFUSED
) : (
res.answer = record.rr
),
new Message(DNS.encode(res)) //6
) : (
_forward = true,
msg
)
)
)()
)
.branch(
() => _forward, $ => $
.connect(() => `${config.upstreamDNSServer}:53`, { protocol: 'udp' }) //7
.handleMessage(
msg => (
(res = DNS.decode(msg.body)) => (
res?.question?.[0]?.name && res?.question?.[0]?.type &&
!res?.rcode && (
setDNS(res.question[0].type + '#' + res.question[0].name,
{
rr: res.answer,
status: res.rcode == local.code.REFUSED ? 'deny' : null
}
)
)
)
)()
),
$ => $
)
下面是自定义记录的内容,与 DNS 应答的格式类似。为了支持智能线路解析,部分记录增加了标签信息:"labels": ["line1"]
。
[
{
"name": "example.com",
"type": "A",
"ttl": 60,
"rdata": "192.168.139.10",
"labels": ["line1"]
},
{
"name": "example.com",
"type": "A",
"ttl": 60,
"rdata": "192.168.139.11",
"labels": ["line2"]
},
...
{
"name": "example.com",
"type": "MX",
"ttl": 600,
"rdata": {
"preference": 10,
"exchange": "mail2.example.com"
}
},
{
"name": "example.com",
"type": "TXT",
"ttl": 600,
"rdata": "hi.pipy!"
},
{
"name": "example.com",
"type": "NS",
"ttl": 600,
"rdata": "ns1.example.com"
},
...
]
智能线路解析的逻辑比较简单,为不同的 IP 范围设置线路标签,在应答时如果记录带有标签就只返回对应标签的记录。
{
"line1": [
"192.168.1.110/32"
],
"line2": [
"127.0.0.1/32"
]
}
启动代理:
$ pipy main.js
如上面配置所示,127.0.0.1
是本机回环网卡的地址,192.168.1.110
本机以太网卡的地址,代理监听在 5300
端口。
首先使用 localhost
访问代理,这样代理获取的客户端 IP 地址为 127.0.0.1
,在查询 example.com
的记录时,直返回来地址对应的线路 line2
的记录 192.168.139.11
。
$ dig @localhost -p 5300 a example.com
; <<>> DiG 9.10.6 <<>> @localhost -p 5300 a example.com
; (2 servers found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 25868
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 60 IN A 192.168.139.11
;; Query time: 0 msec
;; SERVER: 127.0.0.1#5300(127.0.0.1)
;; WHEN: Tue Dec 13 21:09:38 CST 2022
;; MSG SIZE rcvd: 56
接着使用 192.168.1.110
访问代理,这次客户端的地址为 192.168.1.110
,返回的是线路 line1
的记录 192.168.139.10
。
$ dig @192.168.1.110 -p 5300 a example.com
; <<>> DiG 9.10.6 <<>> @192.168.1.110 -p 5300 a example.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 54165
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 60 IN A 192.168.139.10
;; Query time: 0 msec
;; SERVER: 192.168.1.110#5300(192.168.1.110)
;; WHEN: Tue Dec 13 21:12:37 CST 2022
;; MSG SIZE rcvd: 56
假如我从另外一台机器上访问,因为没有设置线路,会返回两条记录。
$ dig @192.168.1.110 -p 5300 a example.com
; <<>> DiG 9.16.1-Ubuntu <<>> @192.168.1.110 -p 5300 a example.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 64873
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 60 IN A 192.168.139.10
example.com. 60 IN A 192.168.139.11
;; Query time: 0 msec
;; SERVER: 192.168.1.110#5300(192.168.1.110)
;; WHEN: Tue Dec 13 13:15:24 UTC 2022
;; MSG SIZE rcvd: 83
因为只设置了 A 记录的线路,其他类型的记录不受影响。
$ $dig @localhost -p 5300 mx example.com
; <<>> DiG 9.10.6 <<>> @localhost -p 5300 mx example.com
; (2 servers found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 33492
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;example.com. IN MX
;; ANSWER SECTION:
example.com. 600 IN MX 10 mail1.example.com.
example.com. 600 IN MX 10 mail2.example.com.
;; Query time: 0 msec
;; SERVER: 127.0.0.1#5300(127.0.0.1)
;; WHEN: Tue Dec 13 21:18:27 CST 2022
;; MSG SIZE rcvd: 117
对 Pipy 有一定了解的小伙伴可能知道 Repo 模式[4],有兴趣的可以参考这篇文章 快速入门 Pipy Repo(文章一年前发布,界面和 API 接口有更新,但是原理不变)。
使用 Repo 模式,所有主机上的代理(或者称之为 DNS 服务器)都从 Repo 中实时获取自定义记录的更新,并刷新缓存。
碍于篇幅,这里就不深入。有兴趣的小伙伴可以尝试自己实现。
dynamic-dns-resolve至此 Pipy 可以实现的代理又增加了一种。DNS 的应用无处不在,也正因如此从 DNS 层面可以解决问题。
让我们再回到开头提到的问题,在 跨集群流量调度实战 的 demo 中,我们将轻松将请求流量调度到了其他集群进行处理。请求的地址是 http://httpbin.httpbin:8080/
,这里的 httpbin.httbin
是命名空间 httpbin
下 K8s Service httpbin
的域名。但是在集群 cluster-2
中并没有这个 Service,仅在集群 cluster-1
和 cluster-3
中部署,这个地址在集群 cluster-2
中无法解析。
这里使用了个小手段,在网格的初始化容器设置 iptables 规则拦截流量时,也 DNS 的流量也拦截到 sidecar 实现的 DNS 代理(监听在 127.0.0.153:5300
),通过自定义 DNS 记录实现业务流量的拦截。
[1]
DNS: https://en.wikipedia.org/wiki/Domain_Name_System[2]
这里: https://github.com/flomesh-io/pipy-demos/tree/main/pipy-dns-demo[3]
main.js
: https://github.com/flomesh-io/pipy-demos/blob/main/pipy-dns-demo/main.js[4]
Repo 模式: https://flomesh.io/pipy/docs/en/operating/repo/0-intro