给 Pangolin 叠两层 WAF:CrowdSec 查你是谁,雷池看你说什么
上线后的回归检查全是绿的:公网上 37 个域名,状态码和切换前一个不差;SQL 注入发过去返回 403;7 台机器的防火墙都连上了决策中心。
我准备收工写这篇文章,打开博客仓库翻格式的时候,看到了自己半个月前的一条提交记录:
nginx: 读者 IP 改取 EO-Connecting-IP,非 EdgeOne 对端直接 403
这个博客挂在 EdgeOne CDN 后面。我刚装好的那套 WAF 看到的来源 IP 不是读者,而是 CDN 的边缘节点。一个节点后面是成百上千个读者,只要某个爬虫经它刷了一阵 404,整个节点就会被判定为恶意、被封。经过这个节点的所有读者,从此都打不开我的博客。
幸好发现时还没有任何封禁决策生效。这一篇从头讲:为什么是两层,怎么挂到 Pangolin 上,以及这个坑和另外 7 个坑是怎么填的。
上一篇泼的冷水,这次怎么回应
上一篇结尾我对 WAF 的态度是「最后才考虑」,理由有三条:它只管 HTTP;要占一台机器的资源;还要自己终止 TLS,和现有的反向代理抢证书。
那篇写完后,前几步都做完了:SSH 只认 key,fail2ban 调成 3 次永久封禁,暴露面也收紧了。这次轮到 WAF,我的要求是正面回应这三条:
- 只管 HTTP:选一个能同时管 SSH 的。
- 资源:在已有的入口机上跑,不另开机器。
- 抢证书:不另起反向代理,直接挂在 Pangolin 的 Traefik 上当中间件。
为什么是两层
我对比了 CrowdSec 和雷池免费版,发现它们几乎不重叠,守的是同一条链上的不同环节:
| 环节 | CrowdSec | 雷池(免费版) |
|---|---|---|
| 攻击者还没来就先挡 | 社区黑名单 | 需要加入情报共享计划,规模较小 |
| 协议覆盖 | 读日志,SSH、HTTP 都能管,在防火墙层丢包 | 只管 HTTP |
| 多机联动 | 一处发现,所有机器一起封 | 只管自己这个入口 |
| 看请求内容(注入、XSS 及其变形) | 规则匹配:虚拟补丁只覆盖已知 CVE | 语义分析,对变形和绕过更有效 |
| 防 Bot | 验证码 | 人机验证加前端代码加密,更成熟 |
一句话概括:CrowdSec 查你是谁,雷池看你说什么。 CrowdSec 按 IP 的信誉和行为决定放不放你进来,雷池逐个读你发来的请求。
有两点要说明。雷池「误报 0.07%」这类数据是长亭自己测的,我没找到中立的第三方复现。还有人在 issue #1285 里指出,雷池仓库虽然标着 GPL-3.0,核心检测却依赖不开源的库。这两条都不影响我用它,但你应该知道。
整体结构
公网 ─→ ① nftables(7 台都装):CrowdSec 按 IP 丢包,SSH、80/443、Docker 映射端口都管 ─→ ② Traefik 中间件:CrowdSec 插件,查 IP 黑名单 + 虚拟补丁 ─→ ③ Traefik 中间件:雷池插件,语义检测 ─→ Pangolin 路由 → 各站点 / newt 隧道 → 集群几个关键选择:
- 雷池不用它自带的反向代理。 只跑它的检测引擎(detector),由 Traefik 插件把请求发过去检测。证书、路由都还归 Pangolin 管,上一篇说的抢证书问题就不存在了。代价是用不上雷池的人机验证和动态防护,这两个功能依赖它自带的代理。
- CrowdSec 只有一个决策中心。 本地 API(LAPI)跑在入口机上,其他 6 台机器上的 CrowdSec 只读日志、上报告警,自己不做决策;每台都装一个 nftables bouncer,从中心拉封禁名单。
- 顺序是 CrowdSec 在前、雷池在后。 按 IP 封禁几乎没有开销,先把已知的坏人挡掉,雷池只需要检查剩下的请求。
8 个坑
1. 插件在启动时联网下载,下载失败会让全站 404
Traefik 的插件默认在启动时从插件目录下载。Pangolin 的 issue #3435 记录了一次事故:用户以为是 Traefik 3.7.7 的问题,后来查明是插件下载偶发失败。插件没加载成功,所有引用它的路由都会被禁用,77 个路由里 75 个返回 404。
我要把中间件挂在入口上,所有站点都会引用它,一次下载失败就是全站下线。所以两个插件都改成本地加载:把源码连同 vendor 目录放进 plugins-local/,并锁定版本和 commit。
# traefik_config.yml(静态配置)experimental: localPlugins: crowdsec: moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin safeline: moduleName: github.com/chaitin/traefik-safeline# docker-compose.yml,traefik 服务 volumes: - ./config/traefik/plugins-local:/plugins-local:ro2. 雷池插件会把整个上传读进内存
长亭官方的 traefik-safeline 插件很薄:配置只有地址和连接池大小,最近一次发版在 2024 年。我读源码时发现了两个问题:
- 不能排除任何域名。 Pangolin 自己的控制面(newt 隧道的连接走这里)也会被检测,一次误拦,整个集群入口就断了。
- 请求体不设上限。 它依赖的 t1k-go 库用
io.ReadAll把请求体整个读进内存,再送去检测。大文件上传会整个堆在 Traefik 里;流式请求会一直卡到流结束。
我在本地副本上改了几十行,加了两个参数:
// excludeHosts:这些域名直接放行,不送检// maxBodyBytes:请求体超过这个大小或长度未知时,只检测 URL 和请求头if req.Body != nil && req.Body != http.NoBody && (req.ContentLength < 0 || req.ContentLength > s.config.MaxBodyBytes) { origBody, swapped = req.Body, true req.Body, req.ContentLength = http.NoBody, 0}result, err := s.server.DetectHttpRequest(req)if swapped { req.Body, req.ContentLength = origBody, origLen}顺手去掉了它加在每个响应上的 X-Chaitin-waf 头,正常请求就不会暴露「这里有雷池」了。被拦截时返回的 403 正文里仍然写着雷池,这一步只是少暴露一点。
3. 雷池的检测引擎默认只听 Unix socket
按官方文档装好之后,从 Traefik 所在的网络连不上引擎的 8000 端口。引擎默认只监听一个 Unix socket,要在 resources/detector/detector.yml 里加两行才会开 TCP:
bind_addr: 0.0.0.0listen_port: 8000另外三处我也改了:
- 管理后台只绑定本机:写成
127.0.0.1:9443:1443,用ssh -L访问。 - 检测引擎加入 Pangolin 的 Docker 网络:Traefik 通过容器名就能访问到它,不对公网开端口。
- 不启动自带的代理 tengine:插件模式用不到它,而它用的是 host 网络,会在公网监听 65443 端口,还占 320MB 内存。在
compose.override.yaml里给它一个用不到的 profile,它就不会启动。
另外,雷池默认使用 172.22.222.0/24 网段。装之前先用 docker network ls 看一下,我那台机器上的 redis 网络正好是 172.22.0.0/16。
4. CrowdSec 插件默认「出错就全拦」
插件有两个默认值:crowdsecAppsecUnreachableBlock 和 crowdsecAppsecFailureBlock 都是 true。意思是 CrowdSec 的 AppSec 组件一旦连不上或返回错误,所有请求一律拦截。CrowdSec 容器重启的那十几秒,全站都会返回 403。
后面还有雷池兜底,第二层防护出故障不该拖垮整站,我都改成了 false。雷池插件本身连不上引擎时是放行的,这一点源码里写得很清楚。
5. 两套 AppSec 配置叠加,CrowdSec 直接起不来
我照着「虚拟补丁 + 通用规则」的思路,在 AppSec 采集配置里同时写了 appsec-default 和 generic-rules,CrowdSec 随即进入重启循环:
failed to compile the directive "secrule": duplicated rule id 100原因是 appsec-default 本身就已经包含了 vpatch-* 和 generic-* 两类规则,写它一个就够了:
source: appseclisten_addr: 0.0.0.0:7422appsec_config: crowdsecurity/appsec-defaultname: appsec-traefiklabels: type: appsec6. 预演时封禁不生效,原因反而是好消息
正式切换之前,我起了一个一次性的 Traefik 容器,配置和生产一致,只接入 Docker 内网。测试封禁时,我给 203.0.113.50 加了一条封禁决策,再带着 X-Forwarded-For: 203.0.113.50 发请求,返回的是 200。
原因是 Traefik 入口默认会丢弃来自不可信来源的 X-Forwarded-For,再把真实的对端 IP 写进去。插件看到的是测试容器自己的内网 IP,而这个网段在白名单里。
这对生产环境是好事:攻击者没法靠伪造 XFF 冒充别人。预演时我只在测试容器的入口上信任了 Docker 网段,封禁随即生效,返回 403。
7. 日志文件晚于 CrowdSec 出现,就不会被读取
Traefik 的访问日志默认没开,我开启之后,CrowdSec 始终没有读到。原因是 CrowdSec 启动时这个文件还不存在,写死文件名的采集不会接管启动后才出现的文件。改成通配符就好了,日志轮转后也能跟上:
source: filefilenames: - /var/log/traefik/*.loglabels: type: traefikTraefik 自己不切分访问日志,记得配 logrotate,postrotate 里执行 docker kill --signal=USR1 traefik,让它重新打开日志文件。
8. 最险的一个:CDN 后面的站点
回到开头。这个博客的链路是:读者 → EdgeOne 边缘节点 → Pangolin 的 Traefik → 博客的 nginx。Traefik 的访问日志里,博客的来源 IP 全是腾讯的边缘节点。
CrowdSec 的 HTTP 行为检测(爬虫、探测、大量 404 等场景)是按来源 IP 计数的。边缘节点被封之后,封禁会同时下发到 Traefik 插件和入口机的防火墙,这个节点的回源请求会在网络层被直接丢弃,经它访问的读者全部打不开博客。雷池是逐个请求判断,不受影响。
这个问题我在博客的 nginx 上提前防过一次:nginx 自带一个按 IP 计数防 CC 的模块,阈值是 60 秒 300 次,如果不做处理,它会把整个 EdgeOne 节点拉黑 5 分钟。为此仓库里维护了一份 EdgeOne 回源网段清单(IPv4 312 段、IPv6 184 段),由脚本从 EdgeOne 的 DescribeOriginACL 接口生成。这次正好复用它,做两步修正。
第一步,把回源网段加进 CrowdSec 白名单,边缘节点永远不会被封:
cscli allowlists create edgeone -d "EdgeOne origin-pull ranges"cscli allowlists add edgeone $(tr '\n' ' ' < eo-cidrs.txt)第二步,让插件看到真实读者的 IP。 我先用的是 EdgeOne 自己写入的 EO-Connecting-IP 头:
forwardedHeadersCustomName: EO-Connecting-IPforwardedHeadersTrustedIps: [<EdgeOne 回源网段>]经 CDN 访问时,插件确实取到了我的真实 IP。但我又从外网直连源站,手动在请求里带上 EO-Connecting-IP: 198.51.100.99,插件照单全收:
ServeHTTP ip:198.51.100.99 isTrusted:false去读插件的源码,找到了原因:它从右往左找请求头里第一个不在信任名单里的 IP,从来不检查实际连过来的对端在不在信任名单里。用默认的 X-Forwarded-For 时这没问题,因为 Traefik 会先清掉不可信来源带来的 XFF,再把对端 IP 追加到最右边。换成 Traefik 不处理的自定义头,谁都能填任意 IP。
能填任意 IP,问题就不只是绕过封禁了。攻击者可以冒用某个读者的 IP,或者搜索引擎爬虫的 IP,故意触发 AppSec 规则,让 CrowdSec 替他把无辜的人封掉。
最后的修法是用回 Traefik 自己的机制:入口只信任来自 EdgeOne 网段的 XFF,插件用默认的 XFF 解析。
entryPoints: websecure: address: ':443' forwardedHeaders: trustedIPs: [<EdgeOne 回源网段>]# dynamic_config.yml,crowdsec 中间件forwardedHeadersTrustedIps: [<EdgeOne 回源网段>]# 不要设 forwardedHeadersCustomName修完之后三种情况都对了:经 CDN 取到真实 IP;直连时伪造 EO-Connecting-IP 被忽略;直连时伪造 XFF 被 Traefik 丢弃。
代价是 EdgeOne 回源网段现在要在四个地方保持一致:nginx 的 geo 文件、Traefik 入口、CrowdSec 插件、CrowdSec 白名单。EdgeOne 会在网段变更前 14、7、3、1 天发通知,收到时四处要一起更新。
实测数据
预演时在 Docker 内网里测的,后端是 traefik/whoami:
| 测试 | 结果 |
|---|---|
| 正常请求 | 200 |
| URL 里的 SQL 注入 / XSS / 命令注入 / 路径穿越 | 403,都是雷池拦的 |
| 登录表单请求体里的 SQL 注入 | 403,雷池拦的 |
| 排除域名上的 SQL 注入 | 200,放行 |
| 2 MiB 请求体、chunked 流式请求 | 200,没有卡住 |
| 被 CrowdSec 封禁的 IP | 403;其他 IP 为 200 |
| 平均延迟(40 次小请求) | 无防护 1.15ms,两层都开 3.38ms,每个请求约多 2.2ms |
切换生产后:
- 封禁传播:在中心加一条封禁,另一台机器的 nftables 集合里 5 秒内就出现了这条记录。
- 白名单:试着封自己的 IP,LAPI 直接拒绝,提示它在白名单网段里。
- 内存:雷池 6 个容器约 590MB(去掉 tengine 之后),CrowdSec 约 63MB,Traefik 从 55MB 涨到约 100MB。入口机是 4 核、3.6G 内存,现在还剩 1.6G 可用。
- 兼容性:37 个域名的状态码和切换前完全一致,其中包括经 newt 隧道转发到 K8s 集群的那些。
作为对照,CrowdSec 官方基准测得虚拟补丁规则平均增加约 4ms 的响应时间(CrowdSec 文档),和我测到的量级一致。
完整配置
以下省略了与本文无关的部分,<...> 是需要换成你自己的值。
Traefik 入口,顺序很重要:
accessLog: filePath: /var/log/traefik/access.log format: json bufferingSize: 100entryPoints: websecure: address: ':443' forwardedHeaders: trustedIPs: [<CDN 回源网段,没有 CDN 就不写>] http: middlewares: - crowdsec@file - safeline@file - compress@file两个中间件,外加一个给 LAPI 用的 IP 白名单:
http: middlewares: crowdsec: plugin: crowdsec: enabled: true crowdsecMode: stream updateIntervalSeconds: 15 crowdsecLapiHost: crowdsec:8080 crowdsecLapiKeyFile: /etc/traefik/secrets/crowdsec-lapi.key crowdsecAppsecEnabled: true crowdsecAppsecHost: crowdsec:7422 crowdsecAppsecFailureBlock: false # 默认 true:出错就全拦 crowdsecAppsecUnreachableBlock: false # 默认 true:连不上就全拦 forwardedHeadersTrustedIps: [<同上>] clientTrustedIps: [<自己的服务器和内网,不经检测>] safeline: plugin: safeline: # 本地打过补丁的版本 addr: safeline-detector:8000 excludeHosts: - pangolin.example.com # Pangolin 控制面 - crowdsec.example.com # LAPI maxBodyBytes: 1048576 crowdsec-lapi-allowlist: ipAllowList: sourceRange: [<其他几台服务器的出口 IP>]CrowdSec 容器,加进 Pangolin 的 compose:
crowdsec: image: docker.io/crowdsecurity/crowdsec:v1.8.1 container_name: crowdsec restart: unless-stopped env_file: ./crowdsec.env # BOUNCER_KEY_traefik=<随机密钥> environment: COLLECTIONS: "crowdsecurity/traefik crowdsecurity/linux crowdsecurity/sshd crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules crowdsecurity/whitelist-good-actors" volumes: - ./config/crowdsec/etc:/etc/crowdsec - ./config/crowdsec/data:/var/lib/crowdsec/data - ./config/traefik/logs:/var/log/traefik:ro - /var/log:/var/log/host:ro # 宿主机的 auth.log ports: - 127.0.0.1:18080:8080 # 给宿主机上的 firewall bouncer 用LAPI 对其他机器暴露:在 Traefik 里加一个路由,HTTPS 由 Traefik 负责,再挂上面那个 ipAllowList。不在名单里的来源会直接收到 Traefik 返回的 403,连 LAPI 都碰不到。
白名单,放在 LAPI 层,所有机器的检测结果和社区黑名单都会受它约束:
cscli allowlists create infra -d "own hosts"cscli allowlists add infra <每台服务器的出口 IP> <自己的出口网段> 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16其他机器,只采集日志、不做决策:
api: server: enable: falselocal_api_credentials.yaml 里的地址指向中心 LAPI。机器凭证在中心生成,用 cscli machines add <主机名> --auto --url https://crowdsec.example.com -f <文件>。注意 -f - 不会把凭证打印到终端,我在 1.8.1 上试过,什么都没输出。稳妥的做法是写到一个临时文件,读出来后立即删除。firewall bouncer 的 api_url 也指向中心。它默认同时挂在 nftables 的 input 和 forward 两个钩子上,Docker 映射出来的端口走的是 forward,也能管到。
CrowdSec 官方的 packagecloud 源还没有 Ubuntu 26.04 的版本,我在 26.04 上直接用了 24.04(noble)的包,都是静态编译的 Go 程序,装上后一切正常。Ubuntu 自带源里的 crowdsec 还停在 1.4.6,别用。
日常维护
- 遇到误拦:用户会看到带
event_id的 403。最快的止血办法是把对应域名加进excludeHosts,file provider 会热加载,不用重启 Traefik;之后再去雷池后台根据事件 ID 加白名单规则。 - 查看和解封:
cscli decisions list、cscli decisions delete --ip <IP>。 - 升级 Pangolin 前:安装器可能覆盖
traefik_config.yml,本地插件和入口中间件会随之丢失,升级后要检查。 - 升级插件:插件是本地副本,不会自动更新。我在
plugins-local/PROVENANCE.txt里记下了每个插件的版本、commit,以及改过什么。
写这篇的时候,上一篇里那台节拍器已经不在门外了:两个荷兰 IP 在 SSH 层就被永久封禁。现在门口多了两个守卫,一个查你是谁,一个看你说什么。
装 WAF 本身只用了一个晚上,大部分时间都花在确认它不会反过来伤到自己:会不会因为一次下载失败让全站 404,会不会因为一个容器重启把所有请求拦掉,会不会把 CDN 节点当成攻击者,会不会有人伪造一个 IP 就让它去封别人。如果你也打算装,留出预演的时间,并提前想好回滚:我的每一步都先在一次性容器里跑通,原始配置也都备份着,随时能退回去。