记使用Cloudflare Workers搭建DOH服务器
前言
一直想用自己的域名做一个dns服务器。adguard dns的自定义域名需要企业版,cloudflare zero的dns也不支持自定义域名。网上虽然很多使用cloudflare worker搭建的dns服务器,也可以自定义域名。但这里有个问题,cfworker搭建的dns服务器,客户端发起dns查询,worker请求上游dns服务器,上游dns服务器会根据worker的ip地址返回最优ip。这样的结果对于客户端来说并不是最优解。后来了解到有些dns支持edns_client_subnet参数,doh请求的时候edns_client_subnet设置成客户端的ip地址,那么上游dns服务器就会根据这个地址来返回最优ip。原理有了,接下来就是实现!
简单实现
1.首先从网上随便找一个基于cloudflare workers的doh程序比如这个。
2.修改代码,增加edns_client_subnet
3.部署
OK!一个简单的私人dns服务器就搭建好了。
经过测试发现,nextdns和dns.google对edns_client_subnet支持很友好,doh请求时修改edns_client_subnet的IP地址,会返回对应地区的ip解析。
eg: https://dns.google/resolve?name=www.taobao.com&edns_client_subnet=223.5.5.5/24
eg: https://dns.nextdns.io/dns-query?name=www.taobao.com&edns_client_subnet=223.5.5.5/24
调试的过程中,发现有的dns支持/resolve,有的dns支持/dns-query。我也想让自己搭建的dns有更完善的功能,使其能够同时支持/dns-query?name=xxx、/dns-query?dns=xxx、/resolve?name=xxx。
我想到了一个方法,worker上的程序只调用上游dns服务器的application/dns-json接口,得到域名解析的json数据。然后根据客户端的不同请求,或者返回json数据,或者返回dns-message数据。
整理一下思路,worker上的程序需要实现的功能:
- 能处理客户端的请求,GET请求需要实现dns-query?name、dns-query?dns、resolve?name接口,POST请求实现dns-query?dns接口
- 能正确识别客户端ip地址,并将其作为edns_client_subnet参数,进行上游dns请求
- 支持json格式转dms-message格式
- 根据不同的客户端请求,将对应的数据格式作为结果返回给客户端。
想法有了,但是我当时并不知道dns-message数据结构的具体含义,也不知道如何将dns-json格式转换成dns-message格式。所以决定使用chatgpt来帮助我实现我的想法。
尝试AI
我将我的想法告诉了chatgpt,它很快就生成了一段代码,还问我要不要实现其它功能(TXT,NS,Edns0….我肯定全都要啊!虽然很多单词我都不知道它是啥,但功能越多越好!越完善越好!这些功能我可以不用,但它不能没有~23333…..)经过一系列的讨价还价,最终定下来的代码的主体框架如下:
1 | // === 构造 DNS 响应 === |
上面的代码有存在两个问题,虽然请求相关api都有返回,但是将adguard home的dns设置成worker后却无法解析剧名甚至导致adguard home崩溃。对于并不了解具体数据格式的我,为了解决这两个问题,我不得不反复调试,并尝试了解RFC 8484 (中英文对照版)和RFC1035的相关内容。
rfc1035&rfc8484
doh响应的数据有两种,一种是json格式:
1 | { |
另一种则是十六进制编码:
1 |
|
1 |
|
Header
前12字节
0000 8180 0001 0004 0000 0000
| ID | FLAGS | QDCOUNT | ANCOUNT | NSCOUNT | ARCOUNT |
|---|---|---|---|---|---|
| 没啥用 | 这是Flags标志位,响应(QR:1),标准查询(Opcode:0000),非权威(AA:0),未截断(TC:0),递归请求(RD:1),递归可用(RA:1),Z:000,无错误(RCODE:0000) | QDCOUNT提问数量 | ANCOUNT答案数量 | NSCOUNT权威记录数量 | ARCOUNT附加记录数量 |
| 0000 | 8180 | 0001 | 0004 | 0000 | 0000 |
Question
03 777777 09 6570696367616d6573 03 636f6d 00
00 1c 00 01
1 | Name: 03 数据长度 77 77 77 www |
Answer1
c00c 0005 0001 0000 07eb 0020
10 77656967687465642d726f772d777777 09 6570696367616d6573 03 636f6d 00
1 | Name: c0 0c 指向数据包第12字节处的域名.即www.epicgames.com |
本条对应的json数据就是
1 | { |
Answer2
c02f 0005 0001 0000 00d6 0026
03 777777 09 6570696367616d6573 03 636f6d 03 63646e 0a 636c6f7564666c617265 03 6e6574 00
1 | Name: c0 2f 指向数据包第47字节处.即weighted-row-www.epicgames.com |
本条对应的json是
1 | { |
Answer3
c05b 0001 0001 0000012c 0004
68 12 14 5e
1 | Name: c05b 指向第91字节域名 www.epicgames.com.cdn.cloudflare.net |
对应的json是
1 | { |
问题分析&解决
AI生成的代码存在两隔问题,
- Name指针固定指向0x0c,不符合递归规律,需要增加一个全局表记录每个CNAME的offset,并在每个Answer的Name跳转处填写正确的offset
1
2
3
4
5
6
7
8const record = concatUint8Arrays([
new Uint8Array([0xc0, 0x0c]), // NAME (pointer to QNAME)
new Uint8Array([(type >> 8) & 0xff, type & 0xff]),
new Uint8Array([0x00, 0x01]), // CLASS IN
encodeTTL(answer.TTL),
new Uint8Array([(rdata.length >> 8) & 0xff, rdata.length & 0xff]),
rdata
]); - dnsNameToLabels 方法结尾bytes.push(0);可能导致域名结束符后面多一个0.需要进行判断,数组末尾如果是0,就不要再补0了
1
2
3
4
5
6
7
8
9
10function dnsNameToLabels(name) {
const parts = name.split('.');
const bytes = [];
for (const part of parts) {
const label = new TextEncoder().encode(part);
bytes.push(label.length, ...label);
}
bytes.push(0); // terminator
return new Uint8Array(bytes);
}
修改后的主体部分代码如下:
1 | // === 构造 DNS 响应 === |
总结
这个程序从开始的想法到最终的实现离不开兴趣的驱动。AI能发挥出的能力受限于我的认知。当我不了解dns-message数据结构的时候,ai生成的程序无法正确的被adguard等软件引用。而我又无法准确的描述问题的根源。只有在我学习并了解相关知识后才能发现问题的根源并进行修复,最终实现这个idea。
参考链接
https://github.com/tina-hello/doh-cf-workers
https://datatracker.ietf.org/doc/html/rfc8484
https://rfc2cn.com/rfc8484.html
https://www.cnblogs.com/bonelee/p/7093709.html