CoreDNS qdoh 插件使用指南:编译安装与实战配置

qdoh 是一个 CoreDNS 的 DNS-over-HTTPS 插件,以 19 行补丁实现独立 HTTPS 服务器,同时提供 RFC 8484 二进制 DoH 和 JSON 解析接口,支持 EDNS0 Client Subnet 自动注入和可选 HTTP/3。

一、背景:为什么需要另一个 DoH 实现?

CoreDNS 官方已经内置了 httpshttps3 插件,为什么还要再造一个轮子?核心原因在于:

  1. 独立服务器控制权:内置 ServerHTTPS 是 CoreDNS 服务器体系的一部分,自定义扩展受到框架约束。qdoh 在 OnStartup 阶段启动完全独立的 http.Server,拥有对超时、并发、TLS 配置的完整控制权。

  2. 双协议端点:现实中我们经常需要同时提供 RFC 8484 二进制 DoH(给浏览器等标准客户端)和 JSON API(给脚本、监控、前端应用),内置插件不直接提供 JSON 端点。

  3. EDNS0 Client Subnet(ECS)自动化:在 CDN 调度、智能 DNS 等场景中,将客户端真实 IP 注入 EDNS0 是刚需。qdoh 原生支持从 HTTP 请求头自动提取客户端 IP 并注入 ECS。

  4. HTTP/3 原生支持:同端口 UDP 监听 QUIC,无需额外端口或额外配置。

与 resolve 插件对比:为什么不直接用 resolve?

你可能注意到了,作者之前还发布了 resolve 插件,同样为 CoreDNS 的 HTTPS 服务器提供 /resolve JSON 接口和 EDNS0 Client Subnet 注入。既然有了 resolve,为什么还要做 qdoh?这不是重复造轮子吗?

答案很简单:resolve 是 qdoh 的前身,qdoh 是 resolve 的完全重写。resolve 解决了”有没有”的问题,qdoh 解决了”好不好”的问题。

resolve 的核心问题

resolve 的设计理念是 侵入式补丁:它直接修改 CoreDNS 框架的 server_https.go,往里面塞了 236 行代码。这带来了几个根本性问题:

维度 resolve qdoh
侵入性 修改 server_https.go(+236 行)、config.go(+3 行)、register.go(+1 行)、plugin.cfg(+2 行),共 +242 行 修改 4 个文件共 +19 行,不碰 server_https.go
代码位置 所有逻辑(serveResolveaddECSFromHTTPclientIPFromRequestresolveToJSONresolveRRData…)都挤在 server_https.go 全部在独立插件目录 plugin/qdoh/ 中,CoreDNS 源码零侵入
服务器控制 依附于 CoreDNS 内置 ServerHTTPS,无法自定义超时、并发限制 独立 http.Server,完整控制 ReadHeaderTimeoutWriteTimeoutMaxHeaderBytes、并发令牌桶
HTTP/3 ❌ 不支持(只修了 server_https.go,没碰 server_https3.go ✅ 原生 HTTP/3(QUIC)同端口支持
/dns-query ECS ✅ 支持(补丁在 ServeHTTP 中注入) ✅ 支持(handleDNSQuery 中注入)
优雅停机 依赖 CoreDNS 框架 独立 stop(),先关 listener 再 drain 请求
并发保护 ❌ 无 maxInFlight 令牌桶(8192)
上游升级 每次升级 CoreDNS 都可能冲突 补丁只加常量和 if 分支,几乎不会冲突

关键技术差异

1. 侵入方式:框架内 vs 独立服务器

resolve 通过修改 server_https.goServeHTTP 方法工作:

1
2
3
4
5
6
7
8
9
// resolve 的做法 — 直接在框架代码中加分支
func (s *ServerHTTPS) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
if r.URL.Path == "/resolve" {
s.serveResolve(w, r) // +236 行全在这个方法里
return
}
// 原有 DoH 逻辑...
}

qdoh 则走了一条完全不同的路 —— 启动独立的 HTTP 服务器

1
2
3
4
5
6
7
8
// qdoh 的做法 — 独立 http.Server,完全不碰框架
func (q *QDOH) start() error {
mux := http.NewServeMux()
mux.HandleFunc("/dns-query", q.handleDNSQuery)
mux.HandleFunc("/resolve", q.handleResolve)
q.srv = &http.Server{Handler: mux, ...}
q.srv.ServeTLS(q.ln, "", "")
}

这意味着 qdoh 拥有对 HTTP 服务器的完整控制权:可以设置 ReadHeaderTimeout 防慢速攻击、MaxHeaderBytes 防内存耗尽、maxInFlight 防雪崩 —— 这些在 resolve 的框架内嵌模式下根本做不到。

2. HTTP/3 支持:缺失 vs 原生

resolve 只修改了 server_https.go,完全没有处理 server_https3.go。也就是说,如果你用 https3:// 传输协议,/resolve 端点根本不存在。

qdoh 用同一个 http.ServeMux 同时服务 TCP 和 UDP,HTTP/3 天然支持:

1
2
q.h3srv = &http3.Server{Handler: mux, ...}
q.h3srv.Serve(q.udpConn)

3. 补丁体积:242 行 vs 19 行

resolve 的补丁往 server_https.go 里塞了 serveResolveresolveToJSONresolveRRDataaddECSFromHTTPclientIPFromRequest 等 236 行代码。这些代码本应是插件自己的职责,却全部堆在框架文件里。

qdoh 的补丁只做了 4 件事:

  • transport.go 加一个 QDOH 常量
  • parse/transport.goqdoh:// 协议识别
  • register.go 加端口默认值
  • plugin.cfg 加一行插件注册

19 行 vs 242 行,维护成本天壤之别。

4. 性能优化:无 vs sync.Pool

resolve 在 serveResolve 中每次请求都分配 DoHWriternet.TCPAddrPack() buffer、cacheControl 字符串拼接,没有任何复用机制。

qdoh 通过 sync.Pool 复用了 responseWriter 对象和 PackBuffer 缓冲区,cacheControl 使用 strconv.AppendUint + Pool 零拼接,每请求减少 3 次分配、94 字节内存。

总结:resolve → qdoh 的演进

resolve 证明了”在 CoreDNS 的 HTTPS 服务器上增加 /resolve JSON 接口”这个想法是可行的。但它采用的侵入式补丁方式——直接往框架代码里塞 200+ 行业务逻辑——在实践中维护成本极高、功能受限(无 HTTP/3、无并发控制)、升级时频繁冲突。

qdoh 是对这个想法的重新实现:把所有逻辑移出框架,用最小侵入的 19 行补丁注册传输协议,然后启动完全独立的 HTTP 服务器。这不是优化,是架构层面的重新设计。

二、功能概览

1
2
3
4
5
6
7
8
qdoh 独立服务器(:443)
├── TCP (HTTPS/2, HTTP/1.1)
│ ├── /dns-query → RFC 8484 二进制 DoH
│ ├── /resolve → JSON 解析接口
│ └── EDNS0 Client Subnet 自动注入
└── UDP (HTTP/3 QUIC) [quic 指令开启]
├── /dns-query → 同上
└── /resolve → 同上

核心特性

特性 说明
独立 HTTPS 服务器 不依赖 CoreDNS 内置 ServerHTTPS,完整控制超时、并发、TLS
/dns-query 标准 DoH 端点(RFC 8484,GET/POST)
/resolve JSON 解析接口(Google DNS 风格)
EDNS0 Client Subnet 自动从 HTTP 请求提取客户端 IP 注入 ECS
HTTP/3 (QUIC) 可选,同端口 UDP 监听
SO_REUSEPORT 平滑 reload,零停机时间
并发保护 令牌桶限流,超过 8192 并发快速返回 503

安全默认值

配置 作用
ReadHeaderTimeout 5s 防慢速攻击(Slowloris)
ReadTimeout 10s 限制完整读取时间
WriteTimeout 30s 限制响应写入时间
MaxHeaderBytes 16KB 防 HTTP 头内存耗尽
MinVersion TLS 1.2 拒绝 TLS 1.0/1.1
maxInFlight 8192 并发上限,防雪崩

EDNS0 Client Subnet 掩码

协议 掩码 说明
IPv4 /24 遵循 RFC 7871 隐私建议
IPv6 /48 遵循 RFC 7871 隐私建议

客户端 IP 优先级:cip 参数 > X-Forwarded-For > X-Real-IP > RemoteAddr

三、与官方插件的兼容性

qdoh 通过标准 dns.ResponseWriter 接口和 context.Context 传递请求,与官方插件兼容:

插件 兼容 说明
forward 正常转发
cache 正常缓存
hosts 正常读取 hosts 文件
log 可记录请求日志
loadbalance 正常负载均衡
rewrite 正常改写
dnssec 正常签名
template 正常模板
errors 正常错误处理
dnstap RemoteAddr 已设置
acl 可基于 IP 过滤
metrics server_addr 标签为 qdoh://<addr>
k8s_external 兼容
view 兼容

四、实战配置示例

4.1 基础部署

1
2
3
4
5
6
7
8
9
10
11
12
13
qdoh://.:443 {
qdoh {
tls /etc/ssl/fullchain.crt /etc/ssl/private.key
edns0 on
resolve
}
forward . 8.8.8.8 1.1.1.1 {
max_concurrent 1000
}
log . "{remote} {type} {name} {rcode} {duration}"
reload 6s
loadbalance
}

4.2 启用 HTTP/3

1
2
3
4
5
6
7
8
9
10
qdoh://.:443 {
qdoh {
tls /etc/ssl/fullchain.crt /etc/ssl/private.key
edns0 on
resolve
quic
}
forward . 8.8.8.8 1.1.1.1
cache 60
}

4.3 Nginx 反向代理

1
2
3
4
5
6
7
8
9
10
location /dns-query {
proxy_pass https://coredns:443;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /resolve {
proxy_pass https://coredns:443;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

五、编译安装

1
2
3
4
5
6
7
8
9
10
git clone https://github.com/coredns/coredns.git coredns
cd coredns
git clone https://github.com/qist/qdoh.git plugin/qdoh

# 应用上游补丁(注册 qdoh 传输协议)
patch -p1 < plugin/qdoh/qdoh.patch

# 生成代码并编译
go generate
make

六、实战验证:从编译到端到端测试

为验证 qdoh 插件的完整功能,我们在 CoreDNS-1.14.7(Go 1.26.6, Linux/amd64)上进行了从编译到端到端的全流程测试。

6.1 编译安装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 克隆 CoreDNS 最新代码
git clone --depth=1 https://github.com/coredns/coredns.git coredns
cd coredns

# 复制 qdoh 插件
cp -r /path/to/qdoh plugin/qdoh

# 应用上游补丁(19 行,4 个文件)
patch -p1 < plugin/qdoh/qdoh.patch
# 输出: 4 files changed, 19 insertions(+)

# 生成代码并编译
go generate # 生成 zplugin.go 和 zdirectives.go,qdoh 已注册
make # 编译成功

# 验证
./coredns -version # CoreDNS-1.14.7
./coredns -plugins # 列表中包含 qdoh

6.2 生成自签名证书

1
2
3
4
5
6
7
mkdir -p /opt/qdoh-test

# 生成 ECDSA P-256 私钥和自签名证书
openssl ecparam -genkey -name prime256v1 -out /opt/qdoh-test/key.pem
openssl req -new -x509 -key /opt/qdoh-test/key.pem -out /opt/qdoh-test/cert.pem \
-days 365 -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"

6.3 Corefile 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.:53 {
forward . 8.8.8.8 1.1.1.1
}

qdoh://.:8443 {
qdoh {
tls /opt/qdoh-test/cert.pem /opt/qdoh-test/key.pem
edns0 on
resolve
}
forward . 8.8.8.8 1.1.1.1 {
max_concurrent 1000
}
log . "{remote} {type} {name} {rcode} {duration}"
errors
}

注意:需要搭配一个 .:53 的普通 DNS server block。qdoh 在 setup() 中设置 conf.ListenHosts = nil 阻止 CoreDNS 框架创建 DNS 服务器,如果没有其他 server block,Caddy 框架会认为没有服务器要启动而直接退出。

6.4 启动服务

1
setsid ./coredns -conf /opt/qdoh-test/Corefile > coredns.log 2>&1 &

启动日志:

1
2
3
4
5
[INFO] plugin/qdoh: compiling plugin chain: 3 plugins
[INFO] plugin/qdoh: chain compiled: Next=true
[INFO] plugin/qdoh: listening on :8443 (HTTPS/2)
CoreDNS-1.14.7
linux/amd64, go1.26.6, 4b26cce-dirty

端口监听验证:

1
2
3
$ ss -tlnp | grep -E '8443|:53'
LISTEN 4096 *:8443 users:(("coredns",pid=19510,fd=4))
LISTEN 4096 *:53 users:(("coredns",pid=19510,fd=7))

6.5 /dns-query 端点测试(RFC 8484)

GET 请求

1
2
3
4
5
6
7
8
9
$ curl -sk "https://localhost:8443/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" \
-o resp.bin -D headers.txt -w "HTTP %{http_code}, %{size_download} bytes"
HTTP 200, 467 bytes

$ cat headers.txt
HTTP/2 200
content-type: application/dns-message
cache-control: max-age=184
content-length: 467

返回了 www.example.com 的 A 记录 104.20.23.154Cache-Control: max-age=184 取自应答的最小 TTL,符合 RFC 8484 规范。

POST 请求

1
2
3
4
$ echo "AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" | base64 -d > query.bin
$ curl -sk -X POST -H "Content-Type: application/dns-message" \
--data-binary @query.bin "https://localhost:8443/dns-query"
# 返回 75 字节 DNS 二进制响应,HTTP 200

6.6 /resolve 端点测试(JSON)

A 记录

1
2
3
4
5
6
7
8
9
10
$ curl -sk "https://localhost:8443/resolve?name=example.com&type=A"
{
"Name": "example.com.",
"Type": "A",
"Status": 0,
"TTL": 163,
"Answer": [
{"name": "example.com.", "type": "A", "ttl": 163, "data": "104.20.23.154"}
]
}

AAAA 记录

1
2
$ curl -sk "https://localhost:8443/resolve?name=example.com&type=AAAA"
{"Name":"example.com.","Type":"AAAA","Status":0,"Answer":[]}

MX 记录

1
2
3
$ curl -sk "https://localhost:8443/resolve?name=example.com&type=MX"
{"Name":"example.com.","Type":"MX","Status":0,"TTL":166,
"Answer":[{"name":"example.com.","type":"MX","ttl":166,"data":"."}]}

默认类型(不传 type,默认 A)

1
2
$ curl -sk "https://localhost:8443/resolve?name=example.com"
# 返回 A 记录,Type 字段为 "A"

6.7 EDNS0 Client Subnet 测试

通过 cip 参数显式指定客户端 IP:

1
2
3
4
5
6
7
$ curl -sk "https://localhost:8443/resolve?name=example.com&cip=8.8.8.8"
{"Name":"example.com.","Type":"A","Status":0,"TTL":140,
"Answer":[{"data":"104.20.23.154"}]}

$ curl -sk "https://localhost:8443/resolve?name=example.com&cip=1.2.3.4"
{"Name":"example.com.","Type":"A","Status":0,"TTL":140,
"Answer":[{"data":"104.20.23.154"}]}

ECS 注入生效,上游 DNS 正确接收了 Client Subnet 信息。

6.8 错误处理测试

场景 HTTP 状态码 响应
/resolvename 参数 400 {"Error":"missing required 'name' parameter"}
/resolve 非法 type 400 {"Error":"invalid query type"}
POST /resolve 405 {"Error":"method not allowed"}
/dns-querydns 参数 400 missing 'dns' query parameter

6.9 日志输出

log 插件正确记录了所有请求,{remote} 占位符显示为客户端真实 IP [::1]

1
2
3
4
[INFO] [::1] A www.example.com. NOERROR 0.881s
[INFO] [::1] A example.com. NOERROR 0.893s
[INFO] [::1] AAAA example.com. NOERROR 0.794s
[INFO] [::1] MX example.com. NOERROR 0.207s

6.10 单元测试

全部 30 个测试用例通过:

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
$ go test -v ./plugin/qdoh/
--- PASS: TestAddECSFromHTTP_XForwardedFor (0.00s)
--- PASS: TestAddECSFromHTTP_XRealIP (0.00s)
--- PASS: TestAddECSFromHTTP_RemoteAddr (0.00s)
--- PASS: TestAddECSFromHTTP_ExistingECS (0.00s)
--- PASS: TestClientIPFromRequest (5 subtests)
--- PASS: TestHandleDNSQuery_GET (0.00s)
--- PASS: TestHandleDNSQuery_GET_MissingDNS (0.00s)
--- PASS: TestHandleDNSQuery_POST (0.00s)
--- PASS: TestHandleDNSQuery_MethodNotAllowed (0.00s)
--- PASS: TestHandleDNSQuery_Overloaded (0.00s)
--- PASS: TestHandleResolve_GET (0.00s)
--- PASS: TestHandleResolve_MissingName (0.00s)
--- PASS: TestHandleResolve_InvalidType (0.00s)
--- PASS: TestHandleResolve_MethodNotAllowed (0.00s)
--- PASS: TestHandleResolve_Overloaded (0.00s)
--- PASS: TestToResolveJSON_Success (0.00s)
--- PASS: TestToResolveJSON_NXDOMAIN (0.00s)
--- PASS: TestToResolveJSON_MultipleAnswers (0.00s)
--- PASS: TestRRData (9 subtests: A/AAAA/CNAME/MX/NS/TXT/SRV/SOA/PTR)
--- PASS: TestMinTTL (0.00s)
--- PASS: TestMinTTL_Empty (0.00s)
--- PASS: TestServerStart_HTTPS (2 subtests: dns-query + resolve)
--- PASS: TestServerStart_ResolveDisabled (0.06s)
--- PASS: TestServerStart_WithEDNS0 (0.06s)
--- PASS: TestSetup (0.00s)
--- PASS: TestParseAddr (0.00s)
--- PASS: TestName (0.00s)
PASS
ok github.com/coredns/coredns/plugin/qdoh 0.370s

6.11 测试结果汇总

测试项 结果 详情
编译 CoreDNS-1.14.7,补丁干净应用无冲突
插件注册 coredns -plugins 列表包含 qdoh
服务启动 HTTPS/2 监听 :8443,3 个插件链编译成功
/dns-query GET HTTP/2 200,application/dns-message,返回正确 A 记录
/dns-query POST 二进制 DNS 查询/响应正常
Cache-Control max-age=184(取最小 TTL),符合 RFC 8484
/resolve A JSON 返回正确 IP,TTL 正确
/resolve AAAA 正确返回空 Answer 数组
/resolve MX 返回 MX 记录
/resolve 默认类型 不传 type 默认 A 记录
ECS cip 参数 cip=8.8.8.8cip=1.2.3.4 均正常注入
错误处理 400/405 状态码正确返回
log 插件 客户端 IP、类型、域名、rcode、耗时正确记录
forward 插件 正常转发到上游 8.8.8.8/1.1.1.1
单元测试 30 个用例全部 PASS,0.370s

七、总结

qdoh 是一个教科书级的 CoreDNS 插件示例,它展示了如何:

  1. 以最小侵入性扩展 CoreDNS:19 行补丁 + ~710 行插件代码,不 fork 上游代码库
  2. 桥接 HTTP 和 DNS 两个世界:通过 dns.ResponseWriter 适配器,让 CoreDNS 插件链透明地处理 HTTP 请求
  3. 延迟初始化策略:利用 OnStartup 回调确保插件链在所有指令解析完毕后编译
  4. 生产级安全实践:TLS 1.2+、慢速攻击防护、请求大小限制、并发上限、快速失败
  5. 隐私与功能的平衡:ECS 注入使用 /24(IPv4)和 /48(IPv6)掩码,遵循 RFC 7871 建议
  6. 全插件兼容:通过 dnsserver.Server stub 修复了 metrics/k8s_external/view 的兼容性问题
  7. 未来兼容:HTTP/3 原生支持,同一端口 UDP QUIC 监听
  8. 实测验证:在 CoreDNS-1.14.7 上编译通过,30 个单元测试全部 PASS,端到端 DoH/JSON/ECS 功能测试全部通过

对于需要自建 DoH 服务的团队来说,qdoh 提供了一个轻量、可定制、与 CoreDNS 生态完全兼容的解决方案。它的代码结构清晰,每个文件职责单一,是学习 CoreDNS 插件开发和 DNS-over-HTTPS 协议实现的优秀参考。


项目地址:github.com/qist/qdoh