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

用 Go 原生编写 Caddy v2 中间件,12 项检测链全开仅约 1.7% 性能开销——支持全局自动生效、域名级差异化规则、IPv4/IPv6 双栈 CIDR 匹配、64 分片滑动窗口 CC 防护、POST body 关键词预过滤,以及无需重启的热加载。

项目地址https://github.com/qist/caddyguard

为什么需要 CaddyGuard?

Caddy v2 因其自动 HTTPS、简洁配置和 Go 原生性能,越来越多的团队将其作为反向代理和 Web 服务器。但 Caddy 生态中缺少一个真正可用的 WAF 插件:

  • ModSecurity 有 Caddy 的 libmodsecurity 封装,但依赖 C 绑定,部署复杂
  • Cloudflare 等 SaaS WAF 是黑盒,规则不可控,且有延迟和隐私顾虑
  • 自己用 Caddy 中间件写?很多人不知道从何入手

CaddyGuard 就是为填补这个空白而生的——一个纯 Go 编写的 Caddy v2 原生 WAF 插件,用标准中间件链实现,零 reflect/unsafe,部署简单到一行 xcaddy build 就搞定。

核心特性

全局自动生效

这是 CaddyGuard 最具特色的设计。通过自定义 caddyguardfile 适配器,全局配置一次 rule_dir 后,所有站点自动启用 WAF,站点块完全不需要写 caddyguard 指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
# 全局 WAF 配置 — 只写一次
caddyguard {
rule_dir /etc/caddyguard/rule-config
}
}

# 站点不需要写 caddyguard,自动生效
example.com {
reverse_proxy 127.0.0.1:8080
}

another.com {
reverse_proxy 127.0.0.1:9090
}

适配器在解析 Caddyfile 后,自动向每个 HTTP server 的每条 route 注入 Guard handler。如果某条 route 已经有 caddyguard handler(如路径级 waf_enable off),则跳过注入,确保路径级配置优先。

12 项检测链

按检测成本从低到高排序,命中即返回,攻击请求在最低成本阶段就被短路拦截:

顺序 检测项 说明
1 白名单 IP 命中放行,跳过所有后续检测
2 白名单 URL 命中放行,跳过所有后续检测
3 动态黑名单 IP CC 自动拉黑期内的 IP
4 静态黑名单 IP 手动配置的 IP 黑名单
5 CC 攻击 64 分片滑动窗口计数
6 User-Agent 恶意扫描器/工具 UA
7 URL 路径 路径遍历、敏感文件、管理后台
8 URL 参数 SQL 注入、XSS、SSTI、RCE
9 Cookie Cookie 注入
10 Referer 恶意来源、支付接口保护
11 POST body 关键词预过滤 + Content-Type 分流
12 文件上传 扩展名检测,最昂贵的检测项

IPv4/IPv6 双栈 IP 匹配

IP 黑白名单同时支持 IPv4 和 IPv6,三种格式全覆盖:

1
2
3
4
5
6
7
8
9
10
11
# CIDR 表示法(推荐)
192.168.1.0/24 # IPv4 CIDR
2001:db8::/32 # IPv6 CIDR

# glob 通配符
192.168.1.* # IPv4 通配符
2001:db8::* # IPv6 通配符

# 精确匹配
8.8.8.8 # IPv4 精确
::1 # IPv6 loopback

在实现上,IPv4 CIDR 在加载阶段被预编译为 uint32 范围,排序后用二分搜索(O(log n));IPv6 CIDR 保存为 *net.IPNet 线性匹配;精确 IP 用 hash map 查找(O(1))。整个 IP 规则集按 mtime 缓存,规则文件未变时直接复用预编译结果。

CDN 代理 IP 信任链

当 CaddyGuard 部署在 CDN 后面时,需要从 X-Forwarded-For 获取真实客户端 IP。但直接信任 XFF 会让攻击者伪造该头绕过 IP 黑白名单。

解决方案是在 cdnip.rule 中配置实际使用的 CDN IP 段:

条件 XFF 处理 说明
remote_addr 在 cdnip.rule 中 信任 XFF 提取真实客户端 IP
remote_addr 不在 cdnip.rule 中 不信任 XFF 使用 remote_addr 防伪造
cdnip.rule 不存在/为空 信任所有 XFF 向后兼容

CC 防护:64 分片滑动窗口

CC 防护是 WAF 的核心功能之一,CaddyGuard 的实现有几个亮点:

  • 64 分片:基于 FNV-1a hash 对 IP+URI 分片,每个分片独立锁,消除全局锁竞争
  • 8 桶滑动窗口:真正的滑动窗口语义(非 TTL 重置),窗口内 8 个时间片桶分别计数
  • 内存上限:CC 计数器最大 100 万 key,超过后自动淘汰过期条目
  • 自动封禁:超过 cc_rate 阈值后自动拉黑 IP,持续 cc_block_ttl
  • 后台清理:每 60 秒清理过期计数器和过期封禁

Go Benchmark 微基准测试显示,CC Incr 操作仅 67 ns/op,IsBanned 仅 25 ns/op——在高并发场景下几乎零开销。

POST body 检测:关键词预过滤

POST body 是最昂贵的检测项(需要读取 body + 正则匹配),CaddyGuard 用三层优化将开销降到最低:

第一层:Content-Type 分流

  • application/x-www-form-urlencoded:通过 ParseQuery 解析 key/value 逐一匹配,完成后跳过 raw body 二次扫描
  • application/json / application/xml:直接走 raw body 扫描
  • multipart/form-data:跳过(由文件上传检测处理),除非开启 multipart_streaming_check

第二层:关键词自动提取预过滤

加载阶段从每条正则规则中自动提取字面量关键词。例如规则 select.+(from|limit) 提取出关键词 select。请求阶段先用 bytes.Contains 检查 body 是否包含任何关键词(Go 的 bytes.Contains 有 SIMD 优化),不包含任何关键词的 body 直接跳过全部正则。

这意味着 99% 的正常 POST 请求(登录、表单提交、API 调用)不会触发任何正则匹配。

第三层:worker 级匹配缓存

对小体积输入(≤512 字节)的匹配结果做全局缓存,key = 规则集标识 + caseInsensitive + input。重复请求(如健康检查)直接复用结果,缓存上限 4096 条。

热加载:2 秒生效,零停机

规则和配置文件修改后 2 秒内自动生效,无需重启 Caddy:

  • 触发方式:修改 rule-config/ 目录下的任何 .rule.json 文件
  • 实现原理:基于 mtime 检查 + 节流(2 秒间隔),避免每个请求都 os.Stat
  • 并发安全:使用 RWMutex + double-check,加载期间请求不受影响
  • 完全进程内:不需要 caddy reload

热加载的实现采用读锁快速路径 + 写锁 double-check 模式:

1
2
3
4
5
6
7
8
9
10
11
// 快速路径:节流窗口内直接用缓存
if entry != nil && !shouldCheck(atomic.LoadInt64(&entry.lastCheckNano)) {
return entry.config
}
// 需要检查 mtime
mtime, exists := getFileMtime(path)
// mtime 未变 → 缓存命中
if entry != nil && entry.modTime == mtime {
return entry.config
}
// 缓存未命中 → 写锁 + double check + 重新加载

域名级配置 + 路径级开关

域名级覆盖:通过 domain.json 为不同域名配置不同的检测策略,支持精确域名和通配符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"www.example.com": {
"url_check": "off",
"cc_rate": "100/60",
"rule_dir": "domains/www.example.com"
},
"api.example.com": {
"waf_enable": "off"
},
"*.test.com": {
"post_check": "off",
"cookie_check": "off"
}
}

域名级配置在加载阶段预合并到全局基线中,请求阶段只需一次 map 查找(O(1)),零运行时开销。

路径级开关:利用 Caddy 原生 path matcher,对特定 URL 路径关闭 WAF:

1
2
3
4
5
6
7
sub.example.com {
@webhook path /api/webhook/*
route @webhook {
caddyguard waf_enable off
reverse_proxy 127.0.0.1:8080
}
}

编码绕过防护

CaddyGuard 支持递归解码,防止攻击者通过多层编码绕过规则:

  • URL 编码:递归 URL-decode(最多 8 层),如 %25253C%253C%3C<
  • JS Unicode\u003C<\u{003C}<
  • JS Hex\x3C<
  • HTML 实体&#x3C;<&#60;<

正常流量(不含 %\u\x&# 标记)走快速路径,零解码开销。

ReDoS 安全

CaddyGuard 基于 Go 的 RE2 正则引擎,无回溯爆炸风险。即使规则文件中意外引入了恶意正则(如 (a+)+$),也不会导致 CPU 100% 或进程挂起。

三种配置方式

CaddyGuard 提供三种配置方式,适应不同场景:

caddyguardfile 适配器 caddyfile 适配器 JSON
全局配置 ✅ 一次配置,所有站点自动生效 ❌ 每站点单独写 ❌ 每个 route 手动写
启动参数 --adapter caddyguardfile --adapter caddyfile 不需要 adapter
推荐场景 多站点统一 WAF 单站点/精细控制 Docker / K8s

性能测试结果

压测环境

  • 物理机(4 核 CPU),本机回环
  • Apache Bench (ab),50000 请求,200 并发,Keep-Alive

核心数据

场景 req/s P99 开销
Caddy + reverse_proxy(无 WAF) 6,329 73ms 基准
CaddyGuard 规则全关 6,405 70ms ~0%
CaddyGuard 规则全开(不含 CC) 6,223 73ms ~1.7%
CaddyGuard 攻击 UA 拦截 11,027 21ms
CaddyGuard + POST body 5,363 86ms ~15%

WAF 全开吞吐下降仅 1.7%(6,223 vs 6,329),几乎可以忽略不计。

优化前后对比

场景 优化前 req/s 优化后 req/s 提升
WAF 全开(不含 CC) 5,713 6,223 +8.9%
WAF + POST body 4,991 5,363 +7.4%
WAF 全开 vs 无 WAF 开销 ~11% ~1.7% 降低 85%

Go Benchmark 微基准

组件 性能 说明
CC Incr (64 分片并行) 67 ns/op 无锁竞争
CC IsBanned (并行) 25 ns/op 分片读锁
matchRules (大小写不敏感) 2.2 ns/op ToLower 一次 + worker 缓存
GetEffectiveConfig 1,474 ns/op 预合并缓存 O(1)
runChecks (正常请求) 6,431 ns/op 12 项检测全通过

性能优化措施详解

优化项 说明
规则双引擎 纯字符串规则用 strings.Contains(快 10x),正则规则用预编译 *regexp.Regexp
关键词预过滤 加载阶段从正则提取字面量关键词,请求阶段 bytes.Contains 预检跳过不匹配的正则
worker 级匹配缓存 小输入(≤512字节)匹配结果全局缓存,上限 4096 条
POST Content-Type 分流 form-urlencoded 走 ParseQuery 解析 key/value 后跳过 raw body 二次扫描
空规则提前返回 各检测函数获取规则后立即判断空规则,避免无意义的 header/body 读取
UA bloom-filter 预检 7 个 bot 标记词预过滤,99% 正常流量跳过白名单遍历
URL 参数短路 无查询参数时直接返回,跳过 pairs 循环和正则匹配
最小输入长度检查 输入 < 2 字符直接跳过规则匹配
请求类型短路 bodyless="on" 时 GET/HEAD/OPTIONS 跳过 POST 和文件上传检测
Cookie 检测后移 Cookie 检测移到 URL/Args 之后,攻击请求在 URL 阶段即短路返回
IP 预编译缓存 CIDR 排序 + 二分搜索 + 精确 IP hash 查找
配置预合并 域名级配置在加载阶段预合并,请求阶段 O(1) 查找
请求上下文缓存 clientIP 和 URI 在 context 中缓存,同请求内不重复计算

稳定性测试

测试项 结果 说明
持续压测 10 分钟 内存零增长
100 万随机 URL CC 攻击 内存仅增长 5MB
32MB+ multipart 文件 未崩溃
热加载规则一致性 5s 内生效,服务不中断
ReDoS 恶意正则 Go RE2 无回溯
超长 URL (100KB) 未崩溃
1000 个 Cookie 未崩溃
回归测试 156/156 全部通过
44 种攻击拦截 全部正确拦截 (403)
IPv4/IPv6 CIDR 拦截 正确拦截
日志字段截断保护 10KB URL 截断到 4110 字节
cc_rate 无效配置 CC 自动禁用 + 错误日志

项目结构

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
caddyguard/
├── module.go # Caddy 模块注册 + Caddyfile 解析 + ServeHTTP
├── handler.go # 检测链编排 + WAF 响应输出
├── config.go # Config 结构体定义
├── domain.go # 域名级配置查找(预合并缓存)
├── rules.go # 规则缓存 + 热加载 + 关键词自动提取
├── adapter.go # caddyguardfile 适配器(全局自动注入)
├── matcher.go # 正则匹配引擎(预编译 + 关键词预过滤 + 缓存)
├── storage.go # CC 存储(64 分片 + 滑动窗口)
├── logger.go # 同步日志(Mutex + 100MB 轮转)
├── decode.go # 递归解码(URL/JS Unicode/HTML 实体)
├── request_context.go # 请求上下文缓存
├── ip_cache.go # IP 规则预编译缓存(CIDR 二分搜索)
├── detector_ip.go # IP 黑白名单检测
├── detector_url.go # URL 路径 + 参数检测
├── detector_ua.go # User-Agent 检测
├── detector_cookie.go # Cookie 检测
├── detector_post.go # POST body 检测
├── detector_cc.go # CC 攻击检测
├── detector_referer.go # Referer 检测
├── detector_fileupload.go # 文件上传检测
├── rule-config/ # 规则配置文件
│ ├── config.json # 全局 WAF 配置
│ ├── domain.json # 域名级配置覆盖
│ ├── *.rule # 规则文件
│ └── domains/ # 域名级独立规则目录
└── test-config/ # 测试 Caddyfile + 压测脚本

快速开始

编译

1
2
3
4
5
6
7
8
9
10
11
12
# 安装 xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# 从 GitHub 编译
xcaddy build --with github.com/qist/caddyguard --output ./caddy

# 验证
./caddy list-modules | grep caddyguard
# 输出:
# caddy.adapters.caddyguardfile
# caddyguard
# http.handlers.caddyguard

配置

1
2
3
4
5
6
7
8
9
{
caddyguard {
rule_dir /etc/caddyguard/rule-config
}
}

example.com {
reverse_proxy 127.0.0.1:8080
}

启动

1
caddy run --config /etc/caddy/Caddyfile --adapter caddyguardfile

Docker 部署

1
2
3
4
5
6
7
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/qist/caddyguard

FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY rule-config /etc/caddyguard/rule-config
COPY Caddyfile /etc/caddy/Caddyfile

日志

攻击日志同步落盘,不丢失。每行一条 JSON,包含时间戳、客户端 IP、攻击类型、命中规则、请求 URL 等:

1
2
3
4
5
6
7
8
9
10
{
"@timestamp": "2026-08-13T04:30:00Z",
"client_ip": "192.168.2.50",
"local_time": "2026-08-13 12:30:00",
"server_name": "www.example.com",
"user_agent": "sqlmap/1.0",
"attack_method": "UserAgent",
"req_url": "/?id=1+union+select",
"rule_tag": "(sqlmap|nmap|...)"
}

日志文件按天分文件({YYYY-MM-DD}_waf.log),单文件超过 100MB 自动轮转为 .old,字段自动截断到 4096 字节防止单条日志过大。

与 NginxGuard 的对比

CaddyGuard 与同作者的 NginxGuard(基于 Lua + OpenResty)在设计理念上一脉相承,但在实现上有几个关键差异:

NginxGuard CaddyGuard
语言 Lua Go
Web 服务器 Nginx / OpenResty Caddy v2
全局自动生效 Nginx 配置加载时注入 caddyguardfile 适配器自动注入
并发模型 OpenResty 单 worker 天然串行 sync.Mutex / sync.RWMutex
正则引擎 PCRE(有 ReDoS 风险) RE2(无回溯,ReDoS 安全)
CC 存储 Lua shared dict 64 分片 MemoryStore
IP 匹配 Lua FFI + 二分搜索 Go net.ParseCIDR + 二分搜索
性能开销 ~16%(GET 流量) ~1.7%(全规则开启)

两者共享相同的规则文件格式和检测链顺序,可以无缝切换。

总结

CaddyGuard 证明了用 Go 原生编写 Caddy 中间件可以实现企业级 WAF 防护,同时保持极低的性能开销。它的核心设计理念是:

  1. 全局自动生效 — 配置一次,所有站点自动防护
  2. 分层优化 — 从规则预编译到请求短路,每一层都减少不必要的开销
  3. 安全第一 — ReDoS 安全、日志不丢失、超限拦截防部分扫描误放行
  4. 运维友好 — 热加载、域名级配置、路径级开关、JSON 日志

如果你在使用 Caddy v2 并且需要 WAF 防护,不妨试试 CaddyGuard。

项目地址https://github.com/qist/caddyguard

NginxGuard:一款基于 Lua 的高性能轻量级 Web 应用防火墙

在 Nginx 请求处理管道中嵌入 Lua,用不到 1000 行核心代码实现企业级 WAF 防护——支持基于域名的差异化规则、IPv6/CIDR 黑白名单、CC 防护、云凭据探测拦截,GET 流量吞吐仅下降约 16%。

为什么需要又一个 WAF?

市面上的 WAF 方案很多:Cloudflare 等 SaaS WAF 简单但黑盒、ModSecurity 功能强大但过重、商业硬件 WAF 价格不菲。很多团队的实际诉求其实很简单:

  • 轻量:不想引入额外进程,直接在现有 Nginx 上挂 Lua 脚本
  • 可控:规则文件就是纯文本正则,随时改随时生效,不需要重启
  • 多域名:不同域名需要不同防护策略,而不是一刀切
  • 高性能:不能因为加了 WAF 就让吞吐掉一半

NginxGuard 就是围绕这几个目标设计的。

项目架构

整个项目只有 4 个 Lua 文件和一组规则文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
waf/
├── config.lua # 全局配置(开关、日志路径、规则目录等)
├── init.lua # init_by_lua_file 预加载模块
├── access.lua # access_by_lua_file 每请求检测入口
├── lib.lua # 核心库(IP获取、规则加载、域名配置、日志、输出)
├── nginx-config/
│ └── nginx.conf # nginx 配置示例
└── rule-config/
├── domain.json # 域名级规则配置
├── args.rule # URL 参数规则
├── blackip.rule # 黑名单 IP
├── cdnip.rule # CDN/可信代理 IP 列表
├── cookie.rule # Cookie 规则
├── post.rule # POST 规则
├── url.rule # URL 规则
├── useragent.rule # User-Agent 规则
├── whiteip.rule # 白名单 IP
├── whiteua.rule # 白名单 UA(搜索引擎爬虫)
├── whiteurl.rule # 白名单 URL
├── referer.rule # Referer 规则
├── fileext.rule # 文件上传扩展名规则
└── domains/ # 域名专属规则目录
└── www.example.com/
└── ...(同构的规则文件)

工作原理

NginxGuard 挂载在 Nginx 的 access_by_lua_file 阶段,每个请求进入时依次执行安全检测:

1
2
3
# nginx.conf 关键配置
init_by_lua_file "/apps/nginx/conf/waf/init.lua";
access_by_lua_file "/apps/nginx/conf/waf/access.lua";

init.lua 在 Nginx 启动时预加载模块;access.lua 在每个请求的 access 阶段执行检测逻辑;lib.lua 是核心库,提供 IP 匹配、规则加载、日志记录等能力。

检测流程

每个请求按以下顺序依次检测,命中任一项即拦截并返回 403,后续检测不再执行:

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
1. $waf_enable 检查(location 级开关,off 则跳过全部)

2. waf_enable 检查(域名级/全局级开关)

3. 白名单 IP 放行

4. 动态黑名单(CC 自动拉黑期内)

5. 静态黑名单 IP

6. 白名单 URL 放行

7. User-Agent 攻击检测(白名单 UA 跳过此项)

8. Referer 攻击检测

9. CC 限速

10. 文件上传扩展名检测

11. URL 路径攻击检测

12. URL 参数攻击检测

13. Cookie 攻击检测

14. POST 攻击检测(表单 + JSON body)

放行

整个流程包裹在 pcall 中,任何意外错误都被捕获并记录到 ngx.log(ngx.ERR),不会导致 500 错误返回给客户端。

核心特性详解

1. 基于域名的差异化规则

这是 NginxGuard 最实用的功能之一。通过 domain.json 配置文件,可以为不同域名设置不同的防护策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"www.example.com": {
"url_check": "off",
"cc_rate": "100/60",
"rule_dir": "domains/www.example.com"
},
"api.example.com": {
"waf_enable": "off"
},
"*.test.com": {
"post_check": "off",
"cookie_check": "off"
}
}

配置优先级location 级 > 域名级 > 全局级

  • config.lua 是全局基线,所有未在 domain.json 中配置的域名都走全局配置
  • domain.json 只放需要覆盖的域名,未指定的字段自动回退到全局
  • 支持通配符域名(*.test.com),精确匹配优先于通配符

规则文件回退机制也设计得很精巧:当域名配置了独立的 rule_dir 时,NginxGuard 会先在域名目录中查找规则文件。如果文件不存在则回退到全局目录;如果文件存在但为空,则不回退,等同于该域名无此项规则。这意味着空文件 = 明确指定”无规则”,而非”使用全局规则”。

2. IP 规则与 CIDR 支持

IP 规则文件(blackip.rule / whiteip.rule / cdnip.rule)全面支持 IPv4/IPv6、CIDR、通配符和精确 IP:

1
2
3
4
5
6
7
# blackip.rule 示例
8.8.8.8 # 精确 IP
10.0.0.0/8 # IPv4 CIDR
2001:db8::/32 # IPv6 CIDR
fe80::* # IPv6 通配符
::1 # IPv6 精确
# 这是注释行,自动跳过

性能方面做了大量优化:

规模 匹配方式 查找复杂度
1195 条 IPv4 CIDR 排序数组 + 二分查找 O(log n) ≈ 11 次比较
IPv6 CIDR 4×32-bit chunk mask 匹配 O(n),通常 <20 条
通配符 预编译 regex O(n),通常 <10 条
精确 IP 小写化字符串比较 O(n),通常 <100 条

CIDR 规则在文件加载时预编译为数值区间,查找性能极高。所有 IP 规则缓存 10 秒 + mtime 检查,修改规则文件后无需 reload nginx。

3. CDN 场景下的 IP 信任链

当 WAF 部署在 CDN 后面时,需要从 X-Forwarded-For 获取真实客户端 IP,但直接信任 XFF 会让攻击者伪造该头绕过 IP 黑白名单。

NginxGuard 的解决方案是 cdnip.rule

1
2
-- config.lua
config_trust_proxy_headers = "on"
1
2
3
4
5
6
# rule-config/cdnip.rule
# Cloudflare IPv4
173.245.48.0/20
104.16.0.0/13
# 内部代理
10.0.0.0/8
条件 XFF 处理 说明
remote_addr 在 cdnip.rule 中 信任 XFF 提取真实客户端 IP
remote_addr 不在 cdnip.rule 中 不信任 XFF 使用 remote_addr(防直连伪造)
cdnip.rule 不存在 信任所有 XFF 向后兼容

IP 提取优先级:CF-Connecting-IP > X-Real-IP > X-Forwarded-For > remote_addr

4. CC 攻击防护与自动拉黑

CC 防护基于 ngx.shared.dict 实现滑动窗口限速:

1
2
3
4
-- 每个IP每60秒最多150次请求
config_cc_rate = "150/60"
-- 超限后自动拉黑600秒
config_cc_block_ttl = 600

超限后 IP 自动加入 badGuys 共享字典,600 秒内所有请求直接返回 403,到期后自动解封。这个封禁是跨 Worker 生效的(基于共享字典),不需要外部存储。

cc_rate 参数解析结果也做了 worker 级缓存,避免每次请求都解析字符串。如果配置格式错误(如 "abc"),CC 检测会静默 fail open,同时在 error.log 中记录错误日志。

5. 云凭据探测拦截

url.rule 中内置了针对云服务凭据和敏感配置文件的探测拦截规则,覆盖攻击者常用的扫描路径:

规则 拦截目标
/firebase.*\.json Firebase 配置文件
/gcp-key\.json GCP 服务账号密钥
/\.aws/credentials AWS 凭据文件
/\.gcloud/ GCP 凭据目录
/\.ssh/id_rsa SSH 私钥
/terraform\.tfstate Terraform 状态文件
/.env 环境变量配置
/.git/config Git 仓库配置
/actuator/env Spring Boot Actuator
/swagger-ui API 文档
/druid/ Druid 监控面板
/h2-console H2 数据库控制台

还覆盖了版本控制目录(.git/.svn/)、包管理文件(package.jsoncomposer.jsonPipfile)、管理后台路径(wp-admin/phpmyadmin)、Webshell 探测(shell.phpeval.php)等数百条规则。

6. 多层解码与绕过防护

攻击者经常通过编码绕过 WAF,NginxGuard 的 full_decode 函数实现了递归解码:

1
2
3
-- 第一步:递归 URL 解码(最多8层)
-- 第二步:JS unicode/hex/entity 解码
-- \u003c → < \x3c → < &#60; → < &#x3c; → <

关键优化是 快速特征检测has_encode_markers):只有当输入中包含 %\u\x&# 等编码标记时才触发解码,正常流量零额外开销。

所有检测项(URL、参数、Cookie、POST)都采用”先匹配原始输入 → 未命中且有编码标记 → 解码后再匹配”的双重检测策略。

性能优化

NginxGuard 在性能方面做了大量工程优化,以下是关键措施:

规则引擎优化

双引擎匹配是核心优化之一。规则文件加载时,自动将纯字符串规则和正则规则分离:

  • 纯字符串规则:用 string.find(plain 模式)匹配,比正则快约 10 倍
  • 正则规则:合并为 (?:rule1|rule2|rule3|...) 单一正则,一次匹配代替 N 次循环
1
2
3
4
5
6
7
-- 规则加载时自动分流
if r:find("[()%.%[%]%*%+%?%^%$|\\]") then
table.insert(regex_rules, r) -- 正则规则
else
table.insert(fast_rules, r) -- 纯字符串规则
fast_hash[r] = true -- hash set for O(1) lookup
end

正常流量匹配从 O(N) 降至 O(1),对 300 条规则的 url.rule,原来需要 300 次正则匹配,现在只需 1 次合并正则 + 少量字符串查找。

缓存层次

层级 存储位置 缓存内容 失效策略
Worker 级 Lua 模块变量 规则文件 + 合并正则 + 预编译 glob TTL 10s + mtime 检查
Worker 级 Lua 模块变量 domain.json 解析结果 TTL 10s + mtime 检查
Worker 级 Lua 模块变量 cc_rate 解析结果 配置变更时重解析
请求级 ngx.ctx client_ip / domain / config / headers 每请求自动清除

FFI stat 获取文件修改时间

规则热加载的关键是检测文件 mtime 变化。NginxGuard 使用 LuaJIT FFI 直接调用 libc 的 stat() 函数,无需编译任何 C 模块:

1
2
3
4
5
6
7
8
ffi.cdef[[
struct waf_stat_t {
long long _pad_to_mtime[11];
long long st_mtime;
long long _rest[7];
};
int stat(const char *path, struct waf_stat_t *buf);
]]

支持现代 glibc(stat())和旧版 glibc(__xstat()),兼容 x86_64 和 aarch64。当 FFI 不可用时回退为文件大小 + 60 秒 TTL。

请求级短路优化

  • GET/HEAD/OPTIONS 跳过 body 检测bodyless=on 时跳过 POST 和文件上传检测
  • 无参数 URL 短路next(REQ_ARGS) == nil 时直接返回
  • 最小输入长度检查#input < 2 直接返回,避免对短参数做正则
  • UA bloom-filter 预检查:先检查 7 个 bot 标记词,99% 正常流量跳过 50 次 string.find
  • 内网 IP 快速短路:127.x/10.x/172.x/192.x 跳过完整 CIDR 匹配
  • Cookie 检测后移:Cookie 检测移到 URL/Args 之后,攻击请求在 URL 阶段即短路返回

配置一次加载

所有 20 个配置项在请求入口一次性加载到 ngx.ctx._cfg,避免每检测项重复调用 get_effective_config

1
2
3
4
5
6
7
local CONFIG_KEYS = {
"waf_enable", "white_ip_check", "black_ip_check", ...
}
-- 请求入口一次性加载
for _, k in ipairs(CONFIG_KEYS) do
ctx._cfg[k] = get_effective_config(k)
end

规则匹配缓存

对小体积请求体/参数,按 规则文件 + mtime + flags + input 做 worker 级缓存,重复请求直接复用匹配结果:

1
2
3
4
5
if entry.cache_key and #input <= MATCH_CACHE_INPUT_MAX then
cache_key = entry.cache_key .. "|" .. (flags or "") .. "|" .. input
local cached = worker_match_cache[cache_key]
if cached ~= nil then return cached or nil end
end

压测数据

测试环境

项目 配置
CPU 4 核 x86_64
内存 24 GB
OS Linux 6.18 (RHEL 9)
Nginx 1.31.3 + LuaJIT2 (OpenResty 分支)
测试工具 ApacheBench (ab),200 并发 50000 请求

GET 请求

场景 req/s CPU RSS P99 吞吐下降
基线(WAF 全关) 27,516 273% 174 MB 43ms
+ WAF(无 CC/POST) 22,912 304% 179 MB 34ms 16.7%
+ WAF + CC 23,287 304% 179 MB 30ms 15.4%
+ WAF 全开(生产) 21,240 268% 179 MB 35ms 22.8%

POST 请求

场景 req/s CPU RSS P99 吞吐下降
form POST 基线 31,243 187% 174 MB 27ms
form POST + WAF 21,634 286% 183 MB 29ms 30.7%
JSON POST 基线 32,188 192% 174 MB 39ms
JSON POST + WAF 20,547 292% 182 MB 66ms 36.2%

WAF 关闭时内存 174MB,全开后 179-183MB,仅增加 5-9MB。所有场景 P99 延迟控制在 66ms 以内。

日志系统

同步写入

攻击日志采用同步写入策略,确保在 ngx.exit(403) 前已落盘:

1
2
3
4
local file = io.open(LOG_NAME, "a")
file:write(LOG_LINE .. "\n")
file:flush() -- 强制刷盘
file:close()

只有检测到攻击时才触发日志写入,正常流量零日志开销。

JSON 格式

每条日志为一行 JSON:

1
2
3
4
5
6
7
8
9
10
11
{
"@timestamp": "2026-08-21T08:30:00Z",
"client_ip": "1.2.3.4",
"local_time": "2026-08-21 16:30:00",
"server_name": "www.example.com",
"user_agent": "Mozilla/5.0...",
"attack_method": "Deny_URL_Args",
"req_url": "/?id=1+union+select+1",
"req_data": "-",
"rule_tag": "(?i:union).*select.*from"
}

截断保护与日志轮转

单字段最大 4096 字节,超出追加 ...[truncated]。日志文件超过 100MB 自动轮转为 *.old,轮转检查每 60 秒执行一次,不影响写入性能。

测试覆盖

项目包含完整的测试体系,16 个测试脚本覆盖各种攻击场景:

测试脚本 覆盖范围
waf_full_test.sh 全量规则测试:URL/参数/UA/Cookie/POST/文件上传/IP/XFF
waf_regression_test.sh 回归测试:bodyless/无效cc_rate/日志截断/PUT方法/大body
waf_scenario_test.sh 场景测试:多域名/白名单/动态拉黑
waf_coverage_test.sh 覆盖率测试
test_all_rules.sh 逐条规则验证
benchmark.sh 性能基准测试

测试覆盖的攻击类型包括:SQL 注入、XSS、路径遍历、命令注入、NoSQL 注入、SSTI 模板注入、PHP 代码注入、Log4j、SSRF、XXE、Webshell 探测等。

快速开始

安装依赖

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
# 安装 LuaJIT2
git clone https://github.com/openresty/luajit2.git
cd luajit2 && make -j$(nproc) && make install
ln -sf /usr/local/lib/libluajit-5.1.so.2 /lib64/libluajit-5.1.so.2

# 安装 lua-cjson
git clone https://github.com/openresty/lua-cjson.git
cd lua-cjson
export LUAJIT_LIB=/usr/local/lib
export LUAJIT_INC=/usr/local/include/luajit-2.1
make -j$(nproc) && make install

# 编译 Nginx + lua-nginx-module
git clone --branch v0.10.31 https://github.com/openresty/lua-nginx-module.git
git clone https://github.com/simplresty/ngx_devel_kit.git
wget https://nginx.org/download/nginx-1.31.3.tar.gz
tar -xvf nginx-1.31.3.tar.gz && cd nginx-1.31.3

export LUAJIT_LIB=/usr/local/lib
export LUAJIT_INC=/usr/local/include/luajit-2.1

./configure \
--add-module=../ngx_devel_kit \
--add-module=../lua-nginx-module \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--with-threads \
--with-file-aio
make -j$(nproc) && make install

配置 Nginx

1
2
3
4
5
6
7
8
9
10
http {
lua_package_path "/apps/nginx/conf/waf/?.lua";
lua_shared_dict limit 10m;
lua_shared_dict badGuys 10m;
lua_code_cache on;
lua_need_request_body off;

init_by_lua_file "/apps/nginx/conf/waf/init.lua";
access_by_lua_file "/apps/nginx/conf/waf/access.lua";
}

修改配置

编辑 config.lua 中的日志路径和规则目录:

1
2
config_log_dir = "/apps/nginx/log/"
config_rule_dir = "/apps/nginx/conf/waf/rule-config"

Location 级别开关

如果某些路径需要关闭 WAF(如 gRPC 长连接、WebSocket),只需一行:

1
2
3
4
location /ws {
set $waf_enable off;
proxy_pass http://backend;
}

规则文件示例

URL 参数规则(args.rule / post.rule / cookie.rule)

三份文件内容相同,覆盖 OWASP Top 10 攻击向量:

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
# SQL 注入
(?i:union)[\s/\*!0-9-]*(?:(?:all|distinct)[\s/\*!0-9-]*)?(?i:select)
(?i:select).*from.*information_schema
sleep\((\s*)(\d*)(\s*)\)
benchmark\([^,]*,\s*[^)]*\)
(?i:load_file\()

# XSS
\<(iframe|script|body|img|layer|div|meta|style|base|object|input|svg)
(onmouseover|onerror|onload)\=
javascript\:
document\.cookie

# 命令注入
(?:define|eval|shell_exec|system|passthru|exec)\(
(gopher|doc|php|glob|file|phar)\:\/

# SSTI 模板注入
\{\{[^}]{0,100}(__class__|__subclasses__|__init__|__globals__)\}\}

# Log4j
log4j|jndi:|\$\{.*jndi
\$\{.*lower
\$\{.*env

# NoSQL 注入
\$where[\(:"']
\$regex[\(:"']
\$gt[\(:"']

# SSRF
169\.254\.169\.254
metadata\.google\.internal

User-Agent 规则(useragent.rule)

拦截常见安全扫描工具:

1
2
3
(sqlmap|nikto|nmap|dirb|gobuster|ffuf|nuclei|httpx|feroxbuster|masscan|wpscan)
(Acunetix|Nessus|OpenVAS|BurpSuite|AppScan|Metasploit|CobaltStrike)
(Python-urllib|Python-requests|Go-http-client|scrapy)

白名单 UA(whiteua.rule)

搜索引擎爬虫放行(仅跳过 UA 黑名单检测,其他安全检测仍执行):

1
2
3
4
5
6
Googlebot
Baiduspider
bingbot
YandexBot
Applebot
...

安全设计:白名单 UA 仅跳过 useragent.rule 检测,不会跳过 URL/POST/CC/Cookie 等其他检测,防止攻击者伪造搜索引擎 UA 绕过全部防护。

设计思考

为什么不使用 OpenResty?

NginxGuard 只依赖 lua-nginx-module + LuaJIT + lua-cjson,不需要完整的 OpenResty 发行版。对于已经在用原版 Nginx 的团队,只需加两个动态模块即可获得 WAF 能力,迁移成本极低。

为什么用同步日志?

WAF 日志的核心需求是”不丢”。如果用异步批量写入,在 ngx.exit(403) 后请求立即终止,尚未刷盘的日志可能丢失。同步写入 + flush() 确保每条攻击日志在返回 403 前已落盘。由于只在攻击时触发,正常流量不受影响。

为什么规则文件用纯文本而非 JSON/YAML?

纯文本规则文件的优势在于:

  • 每行一条正则,极致简洁
  • 支持 # 注释,方便分组和说明
  • vim / sed 直接编辑,无需格式化工具
  • 文件修改后通过 mtime 检查自动热加载,无需重启

fail open 设计

整个检测流程包裹在 pcall 中。即使某条规则有语法错误、某个函数抛出异常,请求也不会返回 500,而是被捕获并记录到 error.log。在安全性和可用性之间,NginxGuard 选择了可用性优先(fail open),避免 WAF 自身故障导致全站不可用。

总结

NginxGuard 是一个”够用就好”的 WAF:

  • 轻量:4 个 Lua 文件,核心代码不到 1000 行,不引入额外进程
  • 高性能:GET 吞吐下降约 16%,内存仅增加 5-9MB
  • 多域名:基于 domain.json 的差异化配置,支持通配符和规则回退
  • 热加载:修改规则文件后 10 秒内自动生效,无需 reload nginx
  • 全面防护:覆盖 SQLi/XSS/SSTI/SSRF/Log4j/NoSQL/命令注入/Webshell 探测等
  • IPv6 友好:全链路支持 IPv4/IPv6/CIDR/通配符
  • CDN 适配:通过 cdnip.rule 构建 XFF 信任链,防止 IP 伪造

如果你的团队已经在用 Nginx,需要一个可控、可审计、不拖慢业务的 WAF 方案,NginxGuard 值得一试。


项目地址:GitHub - qist/nginxguard

CoreDNS HTTPDNS 添加resolve 接口

name

CoreDNS 插件:为 HTTPS (DoH) 服务器提供 /resolve JSON 解析接口,支持 EDNS0 Client Subnet。

Download

编译好的二进制文件(含 resolve 插件):

https://github.com/qist/coredns-plugins-suite/releases

编译安装

前置条件

  • Go 1.21+
  • CoreDNS 源码
1
2
3
# 克隆 CoreDNS 源码
git clone https://github.com/coredns/coredns.git coredns
cd coredns

1. 克隆插件

1
git clone https://github.com/qist/resolve.git plugin/resolve

2. 应用上游补丁

1
2
cd  coredns
git apply --3way plugin/resolve/server_https.patch

补丁修改以下文件:

  • core/dnsserver/server_https.go — 新增 /resolve 处理、EDNS0 Client Subnet 注入
  • core/dnsserver/config.go — 新增 ResolveEDNS0 配置字段

3. 注册插件到 plugin.cfg

1
2
3
# 在 https3 行后添加 edns0 和 resolve 指令
#sed -i '/^https3:https3$/a edns0:resolve\nresolve:resolve' plugin.cfg
补丁里面添加了 不需要 手动添加

验证:

1
2
3
4
grep -n "edns0\|resolve" plugin.cfg
# 应输出:
# 32:edns0:resolve
# 33:resolve:resolve

4. 生成代码并编译

1
2
3
4
5
6
7
8
# 生成 zplugin.go 和 zdirectives.go
go generate

# 编译
make

# 验证二进制包含 resolve 插件
strings ./coredns | grep "resolve" | head -3

5. 安装

1
2
3
4
# 替换原有二进制
cp coredns /usr/local/bin/coredns
# 或
cp coredns /opt/coredns/coredns

配置

Corefile

1
2
# 自动添加到 Corefile 的 https server block
sed -i '/tls .*/a\ edns0 on\n resolve' /path/to/Corefile

完整配置:

1
2
3
4
5
6
7
8
9
10
https://.:443 {
tls /path/to/fullchain.crt /path/to/private.key
edns0 on
resolve
forward . 8.8.8.8 1.1.1.1 223.5.5.5 {
max_concurrent 1000
}
log
errors
}

指令说明

指令 说明
resolve 启用 /resolve JSON 接口
edns0 on 自动注入 EDNS0 Client Subnet(默认)
edns0 off 关闭自动注入,仅 cip 参数生效

接口

GET /resolve

1
GET /resolve?name=<domain>&type=<rrtype>&cip=<client_ip>
参数 必填 说明 示例
name 查询域名 example.com
type 记录类型,默认 A AAAAMXCNAMENSTXTSRV
cip 客户端 IP,用于 EDNS0 Client Subnet 8.8.8.8

响应格式

成功(200):

1
2
3
4
5
6
7
8
9
10
{
"Name": "www.qq.com.",
"Type": "A",
"Status": 0,
"TTL": 30,
"Answer": [
{"name": "www.qq.com.", "type": "CNAME", "ttl": 30, "data": "www.qq.com.eo.dnse2.com."},
{"name": "www.qq.com.eo.dnse2.com.", "type": "A", "ttl": 30, "data": "43.159.109.55"}
]
}

错误(400/405/500):

1
{"Error": "missing required 'name' parameter"}

字段说明

字段 类型 说明
Name string 查询域名(FQDN)
Type string 查询记录类型
Status int DNS 响应码(0=成功,3=NXDOMAIN)
TTL uint32 最小 TTL,无记录时为 0
Answer array 解析记录列表
Answer[].name string 记录名称
Answer[].type string 记录类型
Answer[].ttl uint32 记录 TTL
Answer[].data string 记录数据

EDNS0 Client Subnet

行为

默认开启(不写 edns0 指令等同于 edns0 on)。

edns0 cip /resolve /dns-query
on(默认) 自动提取客户端 IP 自动提取客户端 IP
on(默认) 以 cip 为准 自动提取客户端 IP
off 不注入 不注入
off 以 cip 为准 不注入

客户端 IP 优先级

  1. cip 查询参数(仅 /resolve
  2. X-Forwarded-For 头(取第一个 IP)
  3. X-Real-IP
  4. 连接 RemoteAddr

子网掩码

协议 掩码
IPv4 /24
IPv6 /48

反向代理

Nginx 传递真实客户端 IP:

1
2
3
4
5
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
11
12
# 基本查询
curl -sk "https://localhost:443/resolve?name=example.com"

# 指定记录类型
curl -sk "https://localhost:443/resolve?name=example.com&type=AAAA"

# 指定客户端 IP(EDNS0 Client Subnet)
curl -sk "https://localhost:443/resolve?name=example.com&cip=8.8.8.8"

# 格式化输出
curl -sk "https://localhost:443/resolve?name=example.com" | python3 -m json.tool
curl -sk "https://localhost:443/resolve?name=example.com" | jq .

卸载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 从 plugin.cfg 移除
sed -i '/^edns0:resolve$/d; /^resolve:resolve$/d' plugin.cfg

# 2. 从 Corefile 移除
sed -i '/edns0/d; /resolve/d' Corefile

# 3. 回退上游补丁
git apply -R plugin/resolve/server_https.patch

# 4. 删除插件目录
rm -rf plugin/resolve

# 5. 重新编译
go generate && go build

文件说明

1
2
3
4
5
plugin/resolve/
├── README.md # 本文档
├── setup.go # 注册 resolve + edns0 指令,包装 validator
├── resolve.go # 透传 handler(实现 plugin.Handler 接口)
└── server_https.patch # 上游补丁

server_https.patch 改动

文件 行数 改动
core/dnsserver/config.go +3 新增 ResolveEDNS0 bool 字段
core/dnsserver/register.go +1 默认 ResolveEDNS0: true
core/dnsserver/server_https.go +236 serveResolve()、addECSFromHTTP()、clientIPFromRequest()、resolveEDNS0()
plugin.cfg +2 edns0:resolveresolve:resolve

CoreDNS 黑白名单插件hostlist

Name

黑白名单插件 hostlist

概述

hostlist 是一个 CoreDNS 插件,使用 AdGuard HostlistsRegistry 格式的规则进行 DNS 域名过滤。支持黑名单/白名单两种模式,远程规则自动同步,本地缓存。

插件在 tsig 之后执行,优先级高于测速、缓存等插件。

特性

  • 支持 AdGuard 全部 DNS 过滤规则格式
  • 支持 Surge 规则格式(.domain.com
  • 支持 Clash 规则格式(payload: 块中的 '+.domain.com'
  • 黑名单模式(封锁匹配域名)和白名单模式(仅放行匹配域名)
  • 远程 URL 和本地文件两种规则来源
  • 定时自动同步远程规则,支持本地缓存
  • 反向标签 trie 数据结构,50 万+ 域名毫秒级匹配
  • 用户自定义黑白名单,不受远程更新影响
  • 客户端 IP 白名单,指定 IP 绕过家长控制和安全搜索

Corefile 语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
hostlist {
url <remote-url> # 远程规则 URL(可重复)
file <local-path> # 本地规则文件(可重复)
whitelist_url <remote-url> # 远程白名单 URL(可重复)
whitelist_file <local-path> # 本地白名单文件(可重复)
allowlist <rule> # 用户白名单规则(可重复)
blocklist <rule> # 用户黑名单规则(可重复)
mode blacklist|whitelist # 模式,默认 blacklist
block_type 0.0.0.0|nxdomain|empty # 拦截响应类型,默认 0.0.0.0
safesearch on|off # 安全搜索,默认 off
parental on|off # 家长控制,默认 off
parental { # 家长控制独立规则源(可选)
url <remote-url> # 家长控制远程规则 URL(可重复)
file <local-path> # 家长控制本地规则文件(可重复)
}
bypass_ip <ip/cidr> # 客户端 IP 白名单,绕过家长控制和安全搜索(可重复)
refresh <duration> # 远程同步间隔,默认 4d
cache_dir <path> # 缓存目录,默认 ./hostlist/
}

示例配置

基础配置

1
2
3
4
5
6
7
8
9
. {
hostlist {
url https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
url https://adguardteam.github.io/HostlistsRegistry/assets/filter_21.txt
file /etc/coredns/custom_blocklist.txt
refresh 12h
}
forward . 8.8.8.8:53
}

支持 Surge 和 Clash 规则源

1
2
3
4
5
6
7
8
9
10
11
. {
hostlist {
# Surge 格式规则源
url https://raw.githubusercontent.com/Loyalsoldier/surge-rules/release/reject.txt
# Clash 格式规则源
url https://raw.githubusercontent.com/Loyalsoldier/clash-rules/release/reject.txt
mode blacklist
refresh 4d
}
forward . 8.8.8.8:53
}

完整配置

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
. {
hostlist {
# 黑名单来源
url https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
url https://adguardteam.github.io/HostlistsRegistry/assets/filter_29.txt
file /etc/coredns/custom_blocklist.txt

# 白名单来源(整份文件所有规则作为白名单)
whitelist_url https://example.com/my_allowlist.txt
whitelist_file /etc/coredns/allowlist.txt

# 用户自定义规则(不受远程更新影响)
allowlist @@||www.youtube.com^
allowlist @@||m.youtube.com^
blocklist ||ads.example.com^
blocklist ||tracker.myapp.com^

# 设置
mode blacklist
block_type nxdomain
parental off
safesearch off
bypass_ip 192.168.1.100
bypass_ip 10.0.0.0/24
bypass_ip 172.16.0.0/16
refresh 12h
cache_dir /var/lib/coredns/hostlist
}
forward . 8.8.8.8:53
log
}

白名单模式

1
2
3
4
5
6
7
. {
hostlist {
url https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
mode whitelist
}
forward . 8.8.8.8:53
}

白名单模式下,仅放行规则列表中的域名,其他全部拦截。

家长控制 + 安全搜索 + IP 白名单

1
2
3
4
5
6
7
8
9
10
. {
hostlist {
parental on
safesearch on
bypass_ip 192.168.1.100
bypass_ip 10.0.0.0/24
bypass_ip 172.16.0.0/16
}
forward . 8.8.8.8:53
}

bypass_ip 指定的客户端 IP 不受家长控制拦截和安全搜索重写限制,其他客户端正常过滤。

规则格式

封锁规则

格式 示例 说明
||domain^ ||ads.example.com^ 封锁域名及所有子域名
127.0.0.1 domain 127.0.0.1 analytics.163.com 仅封锁精确域名,不含子域名
/REGEX/ /^ads\d*\./ 正则匹配封锁
||*wild*domain^ ||*serror*.wo.com.cn^ 通配符,自动转正则
.domain^ .bbelements.com^ 封锁域名及子域名
domain wykop.pl 封锁域名
.domain.com .cntv.lat Surge 格式,封锁域名及所有子域名(等同于 ||cntv.lat^
payload: - '+.0.avmarket.rs' Clash 格式,+.domain 封锁域名及所有子域名(等同于 ||0.avmarket.rs^

白名单规则

格式 示例 说明
@@||domain^ @@||youtube.com^ 放行域名及所有子域名
@@|domain^ @@|affiliate.notion.so^ 放行(单锚点)
@@||domain^| @@||sedge.nfl.com^| 放行(末尾多余 |
@@/REGEX/ @@/^safe\./ 正则匹配放行

修饰符

修饰符 行为
$important 正常处理(剥离修饰符)
$badfilter 跳过(禁用其他规则,不适用)
$dnsrewrite 跳过(DNS 重写,不适用)

注释

1
2
! 这是注释
# 这也是注释

行为说明

黑名单模式(默认)

  • 匹配 url/file 中的封锁规则 → 拦截
  • 匹配 @@/whitelist_url/whitelist_file 中的白名单规则 → 放行
  • 匹配 allowlist 中的用户白名单 → 放行
  • 匹配 blocklist 中的用户黑名单 → 拦截
  • 白名单优先级最高,即使域名在黑名单中,只要匹配白名单就放行
  • 其他域名 → 放行

白名单模式

  • 匹配 url/file 中的规则 → 放行
  • 匹配 allowlist 中的用户白名单 → 放行
  • 其他域名 → 拦截

规则格式兼容性

插件自动识别并处理多种规则格式:

来源 格式示例 处理方式
AdGuard ||example.com^ 封锁 example.com 及所有子域名
Surge .example.com 等同于 ||example.com^
Clash - ‘+.example.com’ 等同于 ||example.com^
Hosts 127.0.0.1 example.com 仅封锁精确域名 example.com

模式与规则源

mode 参数决定规则的生效方式:

  • blacklist 模式:规则列表中的域名被视为黑名单,匹配则拦截
  • whitelist 模式:规则列表中的域名被视为白名单,仅放行匹配的域名

此设置适用于所有规则源(AdGuard、Surge、Clash 格式)。

拦截响应

  • block_type 0.0.0.0(默认):返回 NOERROR + A 记录 0.0.0.0
  • block_type nxdomain:返回 NXDOMAIN + SOA
  • block_type empty:返回 NOERROR,无应答记录

域名匹配

  • ||domain^ 格式:祖先匹配,封锁 domain 及所有子域名
  • hosts 格式(127.0.0.1 domain):精确匹配,仅封锁 domain 本身
  • 白名单 @@||domain^:祖先匹配,放行 domain 及所有子域名

远程同步

  • 启动时立即加载所有规则
  • refresh 间隔定时重新下载远程规则
  • 下载失败时使用本地缓存文件
  • 所有错误(网络超时、文件不存在等)均跳过,不影响进程

安全搜索

safesearch on 强制搜索引擎使用安全模式,将查询重写到安全搜索域名。

1
2
3
4
5
6
. {
hostlist {
safesearch on
}
forward . 8.8.8.8:53
}

支持的搜索引擎:

引擎 重写目标
Google(190+ 国家域名) forcesafesearch.google.com
Bing strict.bing.com
YouTube restrict.youtube.com
DuckDuckGo safe.duckduckgo.com
Brave safesearch.brave.com
Ecosia strict-safe-search.ecosia.org
Yandex 213.180.193.56
Pixabay safesearch.pixabay.com
Qwant safeapi.qwant.com

查询 www.google.com 时返回 CNAME forcesafesearch.google.com,客户端会自动解析到 Google 安全搜索。

家长控制

parental on 会启用家长控制,并优先加载 parental { ... } 中配置的独立规则源;当自定义家长规则下载失败、文件缺失或最终没有解析出任何规则时,会自动回退到内置的赌博、恶意软件和 NSFW 规则源。

1
2
3
4
5
6
. {
hostlist {
parental on
}
forward . 8.8.8.8:53
}

启用后自动加载:

  • 赌博网站
  • 恶意软件
  • NSFW网站

也可以给家长控制单独指定本地文件和远程 URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
. {
hostlist {
parental on
refresh 12h
cache_dir /var/lib/coredns/hostlist
parental {
url https://adguardteam.github.io/HostlistsRegistry/assets/filter_59.txt
url https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/nsfw.txt
file /etc/coredns/parental-extra.txt
}
}
forward . 8.8.8.8:53
}

说明:

  • parental { ... } 只影响家长控制规则,不会和普通 url / file / blocklist 配置混在一起处理。
  • parental { ... } 继承外层 refreshcache_dir;家长控制缓存目录固定为 <cache_dir>/parental
  • 自定义家长控制规则可用时,优先使用自定义规则。
  • 自定义家长控制规则不可用时,会自动回退到内置规则源作为兜底。

客户端 IP 白名单

bypass_ip 指定客户端 IP 或 CIDR 网段,这些客户端发起的 DNS 查询将绕过家长控制拦截和安全搜索重写,直接放行。

1
2
3
4
5
6
7
8
9
10
. {
hostlist {
parental on
safesearch on
bypass_ip 192.168.1.100 # 单个 IP
bypass_ip 10.0.0.0/24 # CIDR 网段
bypass_ip 2001:db8::/32 # IPv6 网段
}
forward . 8.8.8.8:53
}

适用场景:

  • 家庭网络中,家长设备不受限制,孩子设备受家长控制和安全搜索保护
  • 企业内部,管理设备绕过过滤,员工设备正常过滤
  • 支持 IPv4 和 IPv6 地址,支持 CIDR 网段格式

如果 hostlist 前面还有一层 DNS 转发器(例如 dnsmasqOpenWrt dnsmasq),要让 bypass_ip 按真实客户端 IP 生效,需要让前置转发器把客户端地址通过 EDNS0 Client Subnet (ECS) 传给 CoreDNS。

当前实现会优先读取请求里的 ECS 客户端地址;如果请求里没有 ECS,则回退为连接到 CoreDNS 的对端地址(RemoteAddr)。因此:

  • 客户端直连 CoreDNS 时,bypass_ip 直接按客户端源地址匹配
  • 客户端先到 dnsmasq 再转发到 CoreDNS 时,建议在 dnsmasq 开启 ECS
  • 为了传递完整客户端地址,ECS 掩码应设置为 IPv4 /32IPv6 /128

示例:dnsmasq / OpenWrt dnsmasq 开启 ECS

1
2
3
/etc/dnsmasq.conf
# 传递完整客户端地址给下游 CoreDNS
add-subnet=32,128

示例:hostlist 配合 bypass_ip

1
2
3
4
5
6
7
8
9
. {
hostlist {
parental on
safesearch on
bypass_ip 192.168.100.220
bypass_ip 2001:db8::1234/128
}
forward . 127.0.0.1:5353
}

上面的链路中,如果前置 dnsmasq 已经开启 add-subnet=32,128,那么携带 ECS 192.168.100.220/32/0ECS 2001:db8::1234/128/0 的请求将命中对应的 bypass_ip

缓存目录

远程下载的规则会保存到 cache_dir 目录(默认 ./hostlist/),文件名为 URL 的 SHA256 哈希。重启时优先读取本地缓存,后台再同步远程更新。

Prometheus 指标

指标 类型 说明
coredns_hostlist_blocked_requests_total Counter 被拦截的请求数(标签:server, zone)
coredns_hostlist_domains_loaded Gauge 当前加载的封锁域名数

Download

编译好的二进制文件(含 hostlist 插件):

https://github.com/qist/coredns-plugins-suite/releases

编译

1. 克隆 CoreDNS 源码

1
2
git clone https://github.com/coredns/coredns.git coredns
cd coredns

2. 添加 hostlist 插件

1
git clone https://github.com/qist/hostlist.git plugin/hostlist

3. 注册插件

1
grep -q '^hostlist:hostlist' plugin.cfg || sed -i '/^tsig:tsig$/a hostlist:hostlist' plugin.cfg

这会在 tsig:tsig 之后插入 hostlist:hostlist,如果已存在则跳过。

4. 生成代码并编译

1
2
go generate
go build -o coredns .

5. 验证

1
2
./coredns -version
./coredns -plugins | grep hostlist

使用 Go module 方式添加

如果需要以 module 方式引入(适用于 CoreDNS 自定义构建):

1
2
3
4
# 在 CoreDNS 项目根目录
go mod edit -require github.com/qist/hostlist@latest
go mod edit -replace github.com/qist/hostlist=./plugin/hostlist
go mod tidy

然后在 plugin.cfg 中使用完整包路径:

1
hostlist:github.com/qist/hostlist

常见问题

Q: 规则加载失败怎么办?

A: 插件会使用本地缓存,不会中断 DNS 服务。

Q: 如何验证规则是否生效?

A: 使用 dig @127.0.0.1 example.com 测试,查看日志输出。

Q: 内存占用过高?

A: 插件使用 CompactTrie 数据结构存储 50 万+ 域名规则,内存占用约 300-500MB。可以通过设置 GOGC 环境变量来优化内存占用。

GOGC 是什么?

Go 语言的垃圾回收机制默认在堆内存达到上次回收后的 2 倍时触发(GOGC=100)。降低这个值会让垃圾回收更频繁,但能保持更低的内存占用。

推荐设置:

GOGC 值 内存占用 CPU 使用 适用场景
100(默认) 最高 最低 开发测试
30 中等 中等 生产环境推荐
25 较低 较高 内存紧张环境
20 最低 最高 极低内存环境

设置方法:

1
2
3
4
5
6
7
8
9
# 启动时设置
GOGC=30 ./coredns -conf Corefile

# 或导出环境变量
export GOGC=30
./coredns -conf Corefile

# systemd 服务示例
Environment=GOGC=30

实际效果:

  • 规则数量 50 万+ 时
  • GOGC=100:内存约 500-600MB
  • GOGC=30:内存约 300-400MB
  • GOGC=25:内存约 250-350MB

注意: GOGC 值越低,垃圾回收越频繁,CPU 开销略高。建议在内存充足时使用 GOGC=30,在内存紧张时使用 GOGC=25。一般不推荐低于 20