记使用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上的程序需要实现的功能:

  1. 能处理客户端的请求,GET请求需要实现dns-query?name、dns-query?dns、resolve?name接口,POST请求实现dns-query?dns接口
  2. 能正确识别客户端ip地址,并将其作为edns_client_subnet参数,进行上游dns请求
  3. 支持json格式转dms-message格式
  4. 根据不同的客户端请求,将对应的数据格式作为结果返回给客户端。

想法有了,但是我当时并不知道dns-message数据结构的具体含义,也不知道如何将dns-json格式转换成dns-message格式。所以决定使用chatgpt来帮助我实现我的想法。

尝试AI

我将我的想法告诉了chatgpt,它很快就生成了一段代码,还问我要不要实现其它功能(TXT,NS,Edns0….我肯定全都要啊!虽然很多单词我都不知道它是啥,但功能越多越好!越完善越好!这些功能我可以不用,但它不能没有~23333…..)经过一系列的讨价还价,最终定下来的代码的主体框架如下:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// === 构造 DNS 响应 ===
function buildDnsResponse(query, json, includeEdns) {
const header = query.slice(0, 12);
const question = query.slice(12);

const id = header.slice(0, 2);
const flags = new Uint8Array([0x81, 0x80]); // 标准响应 + 递归
const qdcount = header.slice(4, 6);
const ancount = new Uint8Array([0x00, json.Answer?.length || 0x00]);
const nscount = new Uint8Array([0x00, 0x00]);
const arcount = new Uint8Array([0x00, includeEdns ? 1 : 0]);

const answerRecords = [];
if (json.Answer) {
for (const answer of json.Answer) {
const type = answer.type;

const rdata = buildRdata(type, answer.data);
if (!rdata) continue;

const 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
]);

answerRecords.push(record);
}
}

const sections = [
id, flags, qdcount, ancount, nscount, arcount,
question,
...answerRecords
];

if (includeEdns) {
sections.push(buildEdns0Response());
}

return concatUint8Arrays(sections);
}

// ===== RDATA 构造 =====
function buildRdata(type, data) {
if (type === 1) { // A
return new Uint8Array(data.split('.').map(n => parseInt(n, 10)));
} else if (type === 2||type === 5) {//2-NS 5-CNAME
return dnsNameToLabels(data);
} else if (type === 28) { // AAAA
return parseIPv6ToBytes(data);
} else if (type === 15) { // MX
const [priorityStr, ...hostParts] = data.split(' ');
const priority = parseInt(priorityStr);
const hostname = hostParts.join(' ');
return concatUint8Arrays([
new Uint8Array([(priority >> 8) & 0xff, priority & 0xff]),
dnsNameToLabels(hostname)
]);
} else if (type === 16) {//TXT
const texts = Array.isArray(data) ? data : [data];
const parts = texts.map(t => {
const encoded = new TextEncoder().encode(t);
return new Uint8Array([encoded.length, ...encoded]);
})
return concatUint8Arrays(parts);
} else if (type === 46) { // RRSIG (DNSSEC 签名记录,原样处理为简化)
return new TextEncoder().encode(data);
} else if (type === 6) { // SOA
const [mname, rname, serial, refresh, retry, expire, minimum] = data.split(' ');
return concatUint8Arrays([
dnsNameToLabels(mname),
dnsNameToLabels(rname),
encodeUint32(serial),
encodeUint32(refresh),
encodeUint32(retry),
encodeUint32(expire),
encodeUint32(minimum)
]);
}
else if (type === 33) { // SRV
const [priority, weight, port, target] = data.split(' ');
const targetBytes = dnsNameToLabels(target);
return new Uint8Array([
(priority >> 8) & 0xff, priority & 0xff,
(weight >> 8) & 0xff, weight & 0xff,
(port >> 8) & 0xff, port & 0xff,
...targetBytes
]);
}
return null; // unsupported
}
function 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);
}

上面的代码有存在两个问题,虽然请求相关api都有返回,但是将adguard home的dns设置成worker后却无法解析剧名甚至导致adguard home崩溃。对于并不了解具体数据格式的我,为了解决这两个问题,我不得不反复调试,并尝试了解RFC 8484 (中英文对照版)和RFC1035的相关内容。

rfc1035&rfc8484

doh响应的数据有两种,一种是json格式:

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
{
"Status": 0,
"TC": false,
"RD": true,
"RA": true,
"AD": false,
"CD": false,
"Question": [
{
"name": "www.epicgames.com",
"type": 1
}
],
"Answer": [
{
"name": "www.epicgames.com",
"type": 5,
"TTL": 3600,
"data": "weighted-row-www.epicgames.com."
},
{
"name": "weighted-row-www.epicgames.com",
"type": 5,
"TTL": 300,
"data": "www.epicgames.com.cdn.cloudflare.net."
},
{
"name": "www.epicgames.com.cdn.cloudflare.net",
"type": 1,
"TTL": 300,
"data": "104.18.20.94"
},
{
"name": "www.epicgames.com.cdn.cloudflare.net",
"type": 1,
"TTL": 300,
"data": "104.18.21.94"
}
]
}

另一种则是十六进制编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

0x00, 0x00, 0x81, 0x80, 0x00, 0x01, 0x00, 0x04,
0x00, 0x00, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77,
0x09, 0x65, 0x70, 0x69, 0x63, 0x67, 0x61, 0x6d,
0x65, 0x73, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00,
0x01, 0x00, 0x01, 0xc0, 0x0c, 0x00, 0x05, 0x00,
0x01, 0x00, 0x00, 0x0e, 0x10, 0x00, 0x20, 0x10,
0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x65, 0x64,
0x2d, 0x72, 0x6f, 0x77, 0x2d, 0x77, 0x77, 0x77,
0x09, 0x65, 0x70, 0x69, 0x63, 0x67, 0x61, 0x6d,
0x65, 0x73, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0xc0,
0x2f, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x01,
0x2c, 0x00, 0x26, 0x03, 0x77, 0x77, 0x77, 0x09,
0x65, 0x70, 0x69, 0x63, 0x67, 0x61, 0x6d, 0x65,
0x73, 0x03, 0x63, 0x6f, 0x6d, 0x03, 0x63, 0x64,
0x6e, 0x0a, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x66,
0x6c, 0x61, 0x72, 0x65, 0x03, 0x6e, 0x65, 0x74,
0x00, 0xc0, 0x5b, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0x01, 0x2c, 0x00, 0x04, 0x68, 0x12, 0x14,
0x5e, 0xc0, 0x5b, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0x01, 0x2c, 0x00, 0x04, 0x68, 0x12, 0x15,
0x5e,

整理后
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

//HEAD
0000 8180 0001 0004 0000 0000
//QUESTION
03 777777 09 6570696367616d6573 03 636f6d 00
0001 0001
//ANSWER1
c00c 0005 0001 00000e10 0020
10 77656967687465642d726f772d777777
09 6570696367616d6573
03 636f6d 00
//ANSWER2
c02f 0005 0001 0000012c 0026
03 777777
09 6570696367616d6573
03 636f6d
03 63646e
0a 636c6f7564666c617265
03 6e6574
00
//ANSWER3
c05b 0001 0001 0000012c 0004
68 12 14 5e
//ANSWER4
c05b 0001 0001 0000012c 0004
68 12 15 5e

前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
2
3
4
5
6
7
8
Name:   03 数据长度 77 77 77 www 
09 数据长度 65 70 69 63 67 61 6d 65 73 epicgames
03 数据长度 63 6f 6d com
00 结尾

Type: 00 01 = 1 -> A查询(ipv4)
Class: 00 01 = 1 -> IN(互联网)

Answer1

c00c 0005 0001 0000 07eb 0020
10 77656967687465642d726f772d777777 09 6570696367616d6573 03 636f6d 00

1
2
3
4
5
6
7
8
9
10
Name:       c0 0c 指向数据包第12字节处的域名.即www.epicgames.com
Type: 00 05 CNAME
Class: 00 01 IN
TTL: 00 00 07 eb 2027
RDLENGTH: 00 20 接下来的数据长度是32字节
RDATA: 10 数据长度 77656967687465642d726f772d777777 weighted-row-www
09 数据长度 6570696367616d6573 epicgames
03 数据长度 636f6d com
00 结束符
即CNAME:weighted-row-www.epicgames.com

本条对应的json数据就是

1
2
3
4
5
6
{
"name": "www.epicgames.com",
"type": 5,
"TTL": 3600,
"data": "weighted-row-www.epicgames.com."
}

Answer2

c02f 0005 0001 0000 00d6 0026
03 777777 09 6570696367616d6573 03 636f6d 03 63646e 0a 636c6f7564666c617265 03 6e6574 00

1
2
3
4
5
6
7
8
Name:       c0 2f 指向数据包第47字节处.即weighted-row-www.epicgames.com
Type: 00 05 CNAME
Class: 00 01 IN
TTL: 00 00 00 d6 214
RDLENGTH 00 26 接下来的数据长度是38
RDATA: 03 777777 'www' 09 6570696367616d6573 'epicgames'
03 636f6d 'com' 03 63646e 'cdn' 0a 636c6f7564666c617265 'cloudflare'
03 6e6574 'net' 00 (结束符)

本条对应的json是

1
2
3
4
5
6
{
"name": "weighted-row-www.epicgames.com",
"type": 5,
"TTL": 300,
"data": "www.epicgames.com.cdn.cloudflare.net."
},

Answer3

c05b 0001 0001 0000012c 0004
68 12 14 5e

1
2
3
4
5
Name:   c05b 指向第91字节域名 www.epicgames.com.cdn.cloudflare.net
TYPE: 0001 A
Class: 00 01 IN
TTL: 00 00 01 2c 300
IP: 0004 (长度) 68 12 14 5e -> 104.18.20.94

对应的json是

1
2
3
4
5
6
{
"name": "www.epicgames.com.cdn.cloudflare.net",
"type": 1,
"TTL": 300,
"data": "104.18.20.94"
},

问题分析&解决

AI生成的代码存在两隔问题,

  1. Name指针固定指向0x0c,不符合递归规律,需要增加一个全局表记录每个CNAME的offset,并在每个Answer的Name跳转处填写正确的offset
    1
    2
    3
    4
    5
    6
    7
    8
    const 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
    ]);
  2. dnsNameToLabels 方法结尾bytes.push(0);可能导致域名结束符后面多一个0.需要进行判断,数组末尾如果是0,就不要再补0了
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function 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
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// === 构造 DNS 响应 ===
function buildDnsResponse(query, json, includeEdns) {
const header = query.slice(0, 12);
const question = query.slice(12);

const id = header.slice(0, 2);
const flags = new Uint8Array([0x81, 0x80]); // 标准响应 + 递归
const qdcount = header.slice(4, 6);
const ancount = new Uint8Array([0x00, json.Answer?.length || 0x00]);
const nscount = new Uint8Array([0x00, 0x00]);
const arcount = new Uint8Array([0x00, includeEdns ? 1 : 0]);

const answerRecords = [];
const offsetMap = new Map();
if (json.Answer) {
for (const answer of json.Answer) {
const type = answer.type;
const currentLen =concatUint8Arrays([
id, flags, qdcount, ancount, nscount, arcount,
question,
...answerRecords
]).length;

//console.log('out len:' + currentLen);

const rdata = buildRdata(type, answer.data,offsetMap,currentLen);
if (!rdata) continue;
let offset = 0x0c;
if (offsetMap.has(answer.name)) {
offset = offsetMap.get(answer.name);
console.log('offset:'+offset);
}
console.log('name:'+answer.name+', offset:'+offset);

const record = concatUint8Arrays([
//new Uint8Array([0xc0, 0x0c]), // NAME (pointer to QNAME)
new Uint8Array([0xc0, offset]),
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
]);

answerRecords.push(record);
}
}

const sections = [
id, flags, qdcount, ancount, nscount, arcount,
question,
...answerRecords
];

if (includeEdns) {
sections.push(buildEdns0Response());
}

return concatUint8Arrays(sections);
}
// ===== RDATA 构造 =====
function buildRdata(type, data,offsetMap,currentLen) {
console.log('type:'+type+', data:'+data);
if (type === 1) { // A
return new Uint8Array(data.split('.').map(n => parseInt(n, 10)));
} else if (type === 2||type === 5) {//2-NS 5-CNAME
if (!offsetMap.has(data)) {
let name=data;
if (name[name.length - 1] == '.') {
name = name.substring(0, name.length - 1)
}
offsetMap.set(name, currentLen + 12)
console.log('set map:'+data+',offset:'+(currentLen + 12));
}
return dnsNameToLabels(data);
} else if (type === 28) { // AAAA
return parseIPv6ToBytes(data);
} else if (type === 15) { // MX
const [priorityStr, ...hostParts] = data.split(' ');
const priority = parseInt(priorityStr);
const hostname = hostParts.join(' ');
return concatUint8Arrays([
new Uint8Array([(priority >> 8) & 0xff, priority & 0xff]),
dnsNameToLabels(hostname)
]);
} else if (type === 16) {//TXT
const texts = Array.isArray(data) ? data : [data];
const parts = texts.map(t => {
const encoded = new TextEncoder().encode(t);
return new Uint8Array([encoded.length, ...encoded]);
})
return concatUint8Arrays(parts);
} else if (type === 46) { // RRSIG (DNSSEC 签名记录,原样处理为简化)
return new TextEncoder().encode(data);
} else if (type === 6) { // SOA
const [mname, rname, serial, refresh, retry, expire, minimum] = data.split(' ');
return concatUint8Arrays([
dnsNameToLabels(mname),
dnsNameToLabels(rname),
encodeUint32(serial),
encodeUint32(refresh),
encodeUint32(retry),
encodeUint32(expire),
encodeUint32(minimum)
]);
}
else if (type === 33) { // SRV
const [priority, weight, port, target] = data.split(' ');
const targetBytes = dnsNameToLabels(target);
return new Uint8Array([
(priority >> 8) & 0xff, priority & 0xff,
(weight >> 8) & 0xff, weight & 0xff,
(port >> 8) & 0xff, port & 0xff,
...targetBytes
]);
}
return null; // unsupported
}
function dnsNameToLabels(name) {
const parts = name.split('.');
const bytes = [];
for (const part of parts) {
const label = new TextEncoder().encode(part);
bytes.push(label.length, ...label);
}
if(bytes[bytes.length - 1]!=0){
bytes.push(0); // terminator
}
return new Uint8Array(bytes);
}

总结

这个程序从开始的想法到最终的实现离不开兴趣的驱动。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