用 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

CoreDNS 测速插件 speedcheck

Name

coredns 上游可用性探测插件返回最快IP

speedcheck - 对上游返回的多个 IP 做探测并返回最快的一个 IP。

Description

speedcheck 拦截包含 A/AAAA 记录的 DNS 应答,对每个候选 IP 执行连通性探测(ICMP ping / TCP 连接 / HTTP 请求),从中选出最快的一个返回给客户端。

探测流程:

  1. 向上游转发请求,获取原始应答
  2. 从应答中提取 A/AAAA 记录的 IP 列表
  3. 并发探测所有 IP(受并发上限控制,默认最多 32 个)
  4. 根据 speed-ip-mode 的家族偏好选择最优 IP
  5. 将应答收敛为单个最优 IP 返回

当所有探测均失败时,优先回落到 IPv4 地址;若无 IPv4 则随机返回一个 IP。

Syntax

1
2
3
4
5
6
7
8
9
10
11
speedcheck {
speed-check-mode ping,tcp:80,tcp:443
speed-timeout-mode 3s
speed-check-parallel off
speed-cache-ttl 30s
speed-ip-mode ipv4,ipv6
speed-ip-parallel off # off | on | winner
speed-host-override *.example.com|tcp:443,http:443|ipv4,ipv6
check_http_send "HEAD / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
check_http_expect_alive http_2xx http_3xx http_4xx
}

speed-check-mode

探测模式,支持 none / ping / tcp:<port> / http:<port>,可用逗号组合多种模式。

  • 包含 ping 时:先做 ICMP ping;ping 成功则该 IP 视为成功(仍会继续尝试 tcp/http,但 tcp/http 全失败时仍按 ping 成功处理);ping 失败则继续尝试 tcp/http,任意一个成功即短路
  • 仅配置 ping:只做 ICMP ping 探测
  • 不包含 ping:按配置顺序尝试 tcp/http,任意一个成功即短路
  • 配置为 none:禁用探测(仅在 speed-host-override 中有意义)

speed-timeout-mode

单次探测超时时间,默认 2s

speed-check-parallel

是否并发执行同一 IP 的 tcp/http 探测(on / off),默认 off

开启后所有 tcp/http 探测同时发起,任意一个成功即短路返回,适合目标端口响应差异较大的场景。

speed-cache-ttl

缓存探测结果的时间(按 域名+查询类型 缓存),默认 0(关闭)。过期条目在访问时自动清理。

speed-ip-mode

IP 家族优先级,默认 ipv6,ipv4(优先 IPv6)。

含义
ipv6,ipv4 优先 IPv6,IPv6 全部失败时回落到 IPv4
ipv4,ipv6 优先 IPv4,IPv4 全部失败时回落到 IPv6
ipv4 仅使用 IPv4
ipv6 仅使用 IPv6

speed-ip-parallel

v4/v6 并发竞速模式(off / on / winner),默认 off

开启后忽略 speed-ip-mode 的家族优先级,将所有 A/AAAA 的 IP 合并后并发探测。

含义
off 关闭竞速,按 speed-ip-mode 的家族优先级分别测速
on 并发竞速,请求 A 就测速 A 记录返回最快的 A,请求 AAAA 就测速 AAAA 返回最快的 AAAA
winner AAAA 查询跨家族竞速:收集 AAAA + 向上游请求 A,合并竞速;v6 赢返回 AAAA,v4 赢返回 NOERROR(drop AAAA);A 查询与 on 行为一致

winner 模式行为:

  • TypeA 查询:与 on 相同,只测速 A 记录
  • TypeAAAA 查询:收集 AAAA + 向上游请求 A,合并竞速;v6 赢返回最快 AAAA,v4 赢返回 NOERROR + 非 AAAA 记录(CNAME 等)
  • 全部不通:回落到匹配查询类型的首个 IP

不通的 IP 处理:探测失败的 IP 直接跳过。on 模式仅测速同家族;winner 模式对 AAAA 查询做跨家族竞速。

speed-host-override

按域名覆盖探测模式与 IP 家族选择,可配置多条。支持精确匹配和泛域名匹配。

语法

推荐语法(管道分隔,避免逗号歧义):

1
speed-host-override <host>|<check-mode>|<ip-mode>

兼容语法(逗号或空格分隔,不推荐用于多探测项):

1
2
speed-host-override <host>,<check-mode>,<ip-mode>
speed-host-override <host> <check-mode> <ip-mode>

泛域名

使用 * 前缀匹配所有子域名,逐级向上查找:

配置 foo.example.com a.b.example.com example.com
*.example.com 匹配 匹配 不匹配
*.b.example.com 不匹配 匹配 不匹配

精确匹配优先于泛域名匹配。

ip-mode 详解

行为
ipv4 / v4 仅保留 A 记录;AAAA 查询返回空(促使客户端回落)
ipv6 / v6 仅保留 AAAA 记录;A 查询返回空
ipv4,ipv6 优先 IPv4,IPv4 失败时允许回落到 IPv6
ipv6,ipv4 优先 IPv6,IPv6 失败时允许回落到 IPv4

check-mode 为 none 时

不做任何探测,也不收敛多 IP 为单 IP,仅按 ip-mode 做 A/AAAA 的保留或清空。适用于强制客户端走指定协议家族的场景。

命中 override 后的行为

  • 使用该域名专属的 check-modeip-mode
  • 禁用 speed-ip-parallel 的 v4/v6 竞速(onwinner 模式均不生效)

check_http_send

自定义 HTTP/1.x 探测报文。{host} / {HOST} 会被替换为当前 DNS 查询域名。默认值:

1
GET / HTTP/1.0\r\n\r\n

check_http_expect_alive

HTTP 探测可接受的状态码分类,可多选:

  • http_2xx / http_3xx / http_4xx / http_5xx
  • http_all:接受所有状态码

默认接受所有状态码。

Examples

基础配置

1
2
3
4
5
6
7
8
9
. {
speedcheck {
speed-check-mode ping,tcp:80,tcp:443
speed-timeout-mode 3s
speed-cache-ttl 30s
speed-ip-mode ipv6,ipv4
}
forward . 8.8.8.8
}

HTTP 探测

http:443 会并发尝试 HTTPS/1.x 与 HTTP/3,取更快的一个:

1
2
3
4
5
6
7
8
. {
speedcheck {
speed-check-mode ping,http:80,http:443
speed-timeout-mode 2s
check_http_expect_alive http_2xx http_3xx
}
forward . 8.8.8.8
}

泛域名覆盖

对所有 Google 域名强制 IPv4,对 CDN 域名使用 HTTP 探测:

1
2
3
4
5
6
7
8
9
. {
speedcheck {
speed-check-mode ping,tcp:443
speed-timeout-mode 2s
speed-host-override *.google.com|tcp:443|ipv4
speed-host-override *.cdn.example.com|http:443|ipv6,ipv4
}
forward . 8.8.8.8
}

强制协议家族

对特定域名不做探测,仅强制使用 IPv4:

1
2
3
4
5
6
7
. {
speedcheck {
speed-check-mode ping,tcp:443
speed-host-override legacy.example.org|none|ipv4
}
forward . 8.8.8.8
}

并发竞速模式

开启 v4/v6 竞速,返回最快响应的 IP:

1
2
3
4
5
6
7
8
9
. {
speedcheck {
speed-check-mode tcp:443
speed-timeout-mode 2s
speed-ip-parallel on
speed-check-parallel on
}
forward . 8.8.8.8
}

真正最快 IP 模式

使用 winner 模式,v4/v6 同时测速,真正最快的 IP 获胜。若获胜 IP 的家族与查询类型不同,返回空记录促使客户端回落:

1
2
3
4
5
6
7
8
9
. {
speedcheck {
speed-check-mode tcp:443
speed-timeout-mode 2s
speed-ip-parallel winner
speed-check-parallel on
}
forward . 8.8.8.8
}

例如:客户端查询 A 记录,但 IPv6 的 443 端口响应更快,则返回空 A 记录,客户端会自动尝试 AAAA 查询获取 IPv6 地址。

Notes

  • ICMP ping 需要 CAP_NET_RAW 权限或 root 权限;缺少权限时 ping 探测会静默失败,不影响其他探测模式
  • http:443 探测会并发尝试 HTTPS/1.x 和 HTTP/3(QUIC),取先成功的结果
  • 探测连接不复用,每次探测都是新建连接,以准确测量连接建立延迟
  • 缓存按 域名+查询类型 存储,过期条目在访问时自动清理
  • 探测并发上限为 32 个 IP;超出时排队等待,防止协程爆炸

Download

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

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

Build

  1. 拉取 CoreDNS 源码:
1
2
git clone https://github.com/coredns/coredns.git
cd coredns
  1. plugin.cfg 增加一行(建议放到 cache:cache 前面):
1
2
# 使用 sed 自动添加到 cache:cache 前面
sed -i '/^cache:cache$/i\speedcheck:speedcheck' plugin.cfg
  1. 拉取 speedcheck 模块源码:
1
2
cd coredns
git clone https://github.com/qist/speedcheck.git plugin/speedcheck
  1. 重新生成插件注册代码并编译:
1
2
go generate
make

交叉编译(Linux arm64):

1
make -f Makefile.release release