CoreDNS qdoh 插件使用指南:编译安装与实战配置
qdoh 是一个 CoreDNS 的 DNS-over-HTTPS 插件,以 19 行补丁实现独立 HTTPS 服务器,同时提供 RFC 8484 二进制 DoH 和 JSON 解析接口,支持 EDNS0 Client Subnet 自动注入和可选 HTTP/3。
一、背景:为什么需要另一个 DoH 实现?
CoreDNS 官方已经内置了 https 和 https3 插件,为什么还要再造一个轮子?核心原因在于:
独立服务器控制权:内置
ServerHTTPS是 CoreDNS 服务器体系的一部分,自定义扩展受到框架约束。qdoh 在OnStartup阶段启动完全独立的http.Server,拥有对超时、并发、TLS 配置的完整控制权。双协议端点:现实中我们经常需要同时提供 RFC 8484 二进制 DoH(给浏览器等标准客户端)和 JSON API(给脚本、监控、前端应用),内置插件不直接提供 JSON 端点。
EDNS0 Client Subnet(ECS)自动化:在 CDN 调度、智能 DNS 等场景中,将客户端真实 IP 注入 EDNS0 是刚需。qdoh 原生支持从 HTTP 请求头自动提取客户端 IP 并注入 ECS。
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 |
| 代码位置 | 所有逻辑(serveResolve、addECSFromHTTP、clientIPFromRequest、resolveToJSON、resolveRRData…)都挤在 server_https.go 里 |
全部在独立插件目录 plugin/qdoh/ 中,CoreDNS 源码零侵入 |
| 服务器控制 | 依附于 CoreDNS 内置 ServerHTTPS,无法自定义超时、并发限制 |
独立 http.Server,完整控制 ReadHeaderTimeout、WriteTimeout、MaxHeaderBytes、并发令牌桶 |
| 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.go 的 ServeHTTP 方法工作:
1 | // resolve 的做法 — 直接在框架代码中加分支 |
qdoh 则走了一条完全不同的路 —— 启动独立的 HTTP 服务器:
1 | // qdoh 的做法 — 独立 http.Server,完全不碰框架 |
这意味着 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 | q.h3srv = &http3.Server{Handler: mux, ...} |
3. 补丁体积:242 行 vs 19 行
resolve 的补丁往 server_https.go 里塞了 serveResolve、resolveToJSON、resolveRRData、addECSFromHTTP、clientIPFromRequest 等 236 行代码。这些代码本应是插件自己的职责,却全部堆在框架文件里。
qdoh 的补丁只做了 4 件事:
- 在
transport.go加一个QDOH常量 - 在
parse/transport.go加qdoh://协议识别 - 在
register.go加端口默认值 - 在
plugin.cfg加一行插件注册
19 行 vs 242 行,维护成本天壤之别。
4. 性能优化:无 vs sync.Pool
resolve 在 serveResolve 中每次请求都分配 DoHWriter、net.TCPAddr、Pack() 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 | qdoh 独立服务器(:443) |
核心特性
| 特性 | 说明 |
|---|---|
| 独立 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 | qdoh://.:443 { |
4.2 启用 HTTP/3
1 | qdoh://.:443 { |
4.3 Nginx 反向代理
1 | location /dns-query { |
五、编译安装
1 | git clone https://github.com/coredns/coredns.git coredns |
六、实战验证:从编译到端到端测试
为验证 qdoh 插件的完整功能,我们在 CoreDNS-1.14.7(Go 1.26.6, Linux/amd64)上进行了从编译到端到端的全流程测试。
6.1 编译安装
1 | # 克隆 CoreDNS 最新代码 |
6.2 生成自签名证书
1 | mkdir -p /opt/qdoh-test |
6.3 Corefile 配置
1 | .:53 { |
注意:需要搭配一个
.: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 | [INFO] plugin/qdoh: compiling plugin chain: 3 plugins |
端口监听验证:
1 | $ ss -tlnp | grep -E '8443|:53' |
6.5 /dns-query 端点测试(RFC 8484)
GET 请求:
1 | $ curl -sk "https://localhost:8443/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" \ |
返回了 www.example.com 的 A 记录 104.20.23.154,Cache-Control: max-age=184 取自应答的最小 TTL,符合 RFC 8484 规范。
POST 请求:
1 | $ echo "AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB" | base64 -d > query.bin |
6.6 /resolve 端点测试(JSON)
A 记录:
1 | $ curl -sk "https://localhost:8443/resolve?name=example.com&type=A" |
AAAA 记录:
1 | $ curl -sk "https://localhost:8443/resolve?name=example.com&type=AAAA" |
MX 记录:
1 | $ curl -sk "https://localhost:8443/resolve?name=example.com&type=MX" |
默认类型(不传 type,默认 A):
1 | $ curl -sk "https://localhost:8443/resolve?name=example.com" |
6.7 EDNS0 Client Subnet 测试
通过 cip 参数显式指定客户端 IP:
1 | $ curl -sk "https://localhost:8443/resolve?name=example.com&cip=8.8.8.8" |
ECS 注入生效,上游 DNS 正确接收了 Client Subnet 信息。
6.8 错误处理测试
| 场景 | HTTP 状态码 | 响应 |
|---|---|---|
/resolve 缺 name 参数 |
400 | {"Error":"missing required 'name' parameter"} |
/resolve 非法 type |
400 | {"Error":"invalid query type"} |
POST /resolve |
405 | {"Error":"method not allowed"} |
/dns-query 缺 dns 参数 |
400 | missing 'dns' query parameter |
6.9 日志输出
log 插件正确记录了所有请求,{remote} 占位符显示为客户端真实 IP [::1]:
1 | [INFO] [::1] A www.example.com. NOERROR 0.881s |
6.10 单元测试
全部 30 个测试用例通过:
1 | $ go test -v ./plugin/qdoh/ |
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.8 和 cip=1.2.3.4 均正常注入 |
| 错误处理 | ✅ | 400/405 状态码正确返回 |
| log 插件 | ✅ | 客户端 IP、类型、域名、rcode、耗时正确记录 |
| forward 插件 | ✅ | 正常转发到上游 8.8.8.8/1.1.1.1 |
| 单元测试 | ✅ | 30 个用例全部 PASS,0.370s |
七、总结
qdoh 是一个教科书级的 CoreDNS 插件示例,它展示了如何:
- 以最小侵入性扩展 CoreDNS:19 行补丁 + ~710 行插件代码,不 fork 上游代码库
- 桥接 HTTP 和 DNS 两个世界:通过
dns.ResponseWriter适配器,让 CoreDNS 插件链透明地处理 HTTP 请求 - 延迟初始化策略:利用
OnStartup回调确保插件链在所有指令解析完毕后编译 - 生产级安全实践:TLS 1.2+、慢速攻击防护、请求大小限制、并发上限、快速失败
- 隐私与功能的平衡:ECS 注入使用 /24(IPv4)和 /48(IPv6)掩码,遵循 RFC 7871 建议
- 全插件兼容:通过
dnsserver.Serverstub 修复了 metrics/k8s_external/view 的兼容性问题 - 未来兼容:HTTP/3 原生支持,同一端口 UDP QUIC 监听
- 实测验证:在 CoreDNS-1.14.7 上编译通过,30 个单元测试全部 PASS,端到端 DoH/JSON/ECS 功能测试全部通过
对于需要自建 DoH 服务的团队来说,qdoh 提供了一个轻量、可定制、与 CoreDNS 生态完全兼容的解决方案。它的代码结构清晰,每个文件职责单一,是学习 CoreDNS 插件开发和 DNS-over-HTTPS 协议实现的优秀参考。
项目地址:github.com/qist/qdoh