Files
blog/content/posts/defense-that-stalls.md
T

6.6 KiB
Raw Blame History

title, slug, date, lastmod, draft, description, tags, categories, weight
title slug date lastmod draft description tags categories weight
防线是会自己停摆的 defense-that-stalls 2026-10-22T18:05:00 2026-10-22T18:05:00 false 三天内两处防线静默失效,都是我自己部署时验证过的——交互 shell 里全绿,cron 里悄悄死。是它们变心了吗?不,是我从没在真实的环境里验证过。
运维
安全
自动化
监控
运维手记
44

一、防线,也讲究长期的 commitment

5 台机器,我把"自动封禁脚本"当成给机器装的免疫系统—它们24/7运行在公网,每天几千到上万次的爆破/扫描,我总不能每次都手动去封吧。

所以几个月前,我在每台机器上部署了 auto-ban 脚本:定时扫描日志、识别爆破/扫描 IP、按照阈值封禁。

今天检查发现:其中一台的"免疫系统",两年来一直空转。

原因:它运行依赖的防火墙工具,在那台机器上根本不存在(cron 环境的 PATH 里找不到)。脚本的设计是"失败都不失败"——报错被 2>/dev/null 吞掉了,永久静默。

它每天按时启动,按时退出,日志里写"今天运行正常"。但实际上,一条规则都没生效。

不是防线变坏了,是我从没在真实的环境里验证过它能跑。

这周连续三次同类事故:

  • DE 节点:auto-ban 突然 FATAL,说的是"ipset 不可用"
  • LA 节点:auto-ban 卡死,另一个变体的问题
  • 更早的一台:guard 脚本跑在错的用户身份下,读都读不到自己的数据

二、两种"防线静默失效"的病理

病例 1:FATAL PATHShock 🫠

DE 的 auto-ban 脚本,每天 09:30 开始报:

[Oct 20 09:30:01] FATAL ipset 不可用

每小时一次,共 51 次。

根因:ipset 这个工具装在 /usr/sbin,但 cron 的默认 PATH 里没有 /usr/sbin。脚本里 ipset create ... 找不到命令,直接 FATAL 退出。

为什么没发现?因为部署那天我是在交互 shell(root 全环境 PATH)里验证的——一切正常。一旦走 cron(受限 PATH),当场死。

这是第三次踩 "cron PATH" 这个坑。前面两次是宝塔的 fail2ban 和另一个 guard 脚本。同一个坑,我摔了三次。

病例 2:锁死锁上的 Douglas 阻塞 🔒

LA 的 auto-ban 更隐蔽:进程没报错,但卡住了。

它调用 firewall-cmd --ipset --add-entry 来封 IP。这个调用每条要走一次 D-Bus。正常几条无所谓,但 LA 一天被扫描出 3571 个 IP,要一条条走 D-Bus——几百秒跑不完 → 进程挂住 → flock 锁不释放 → 之后每小时的定时任务都直接 skip("另一个实例在跑")。

结果:BANNED 0 条,而日志看起来还在"正常"输出。

Hook 是:卡住比报错更难发现。报错至少有信号,卡住只在日志末尾留一句 skip,还需要你主动去看。

修复(两台都改完)

  1. DE:脚本顶部加 export PATH=...,把 /usr/sbin 包进来。用 env -i 模拟 cron 环境实测通过,ipset 从 0 → 1066 条。
  2. LA:把 firewall-cmd 调用换成直接 ipset add(内核级,快 10 倍)。实测 108 秒跑完,3,571 条封禁 + state 同步。

三、为什么"测试时好的"会变成"运行时死的"

部署时的验证环境 ≠ 运行时的真实环境。

同一个脚本,我用 root 的全功能 shell 验证:PATH 全、权限全、环境变量全、依赖全。但上了 cron,它拿着一个被限制的 PATH 运行。唯一区别是我测试的那条路径和它实际跑的那条路径不同。

这不是脚本 bug——脚本逻辑本身没错。是部署流程的 bug:我把"我在交互 shell 里跑通了"当成了"它能跑"。

如果换成工程师的术语:这是我没有验证运行时依赖,只验证了逻辑依赖。

三条破法(教训)

  1. 验证必须用真实环境跑一遍:env -i(清空全部环境变量)模拟 cron,能在这种环境下 live true 的才算过
  2. 关键参数用绝对路径:不依赖 PATH(/usr/sbin/ipset 而不是 ipset)
  3. 别 2>/dev/null 吞掉 startup 错误:如果它死了至少告诉我一声,不然我根本不知道防线停了

四、今天顺手扫了一圈全舰队的攻防态势

排查完防线停摆,我做了一轮全舰队今日实况(只看今天):

节点 SSH 爆破(今日) 成功登录 封禁中
宁波 11,465 1,134 632
DE 1,711 50 1,066
AU 1,247 87 278
SG 525 41 106
LA 0(SSH 在非标端口) 40 3,571

所有成功登录都来自我自己的 IP(除了一次奇怪但无害的 root 密码登录,同网段有一个我们的客户端,判断是用户自己)。

Web 扫描:宁波主力站点 404 靠 WAF 全是 0;LA 没有 WAF,全靠 auto-ban 硬封 3571 IP 撑着。

结论:有,而且很活跃;但没有被攻破。 一个风险信号都没有。

但注意这个数字——如果防线的停摆没被发现,这些"封禁中"的数字,有的会是 0。


五、最让我后怕的一点:

"防线在工作的证据" 和 "防线在工作" 是两回事

回过头看,我靠着两样东西了解防线状态:

  • 脚本跑得欢:日志里有输出("今天运行正常")
  • 封禁列表有数字:DE 显示还残留着几条历史的封禁

但这两样都是**"防线在工作的证据",而不是"防线在工作"本身。证据可以是旧的(残留的历史数字),也可以是假的(主动打印的成功字样)。防线本身,只有在真实封一次攻击者**时才被证明工作。

所以我今天给自己加了一条新判据:

封禁数长期为 0,本身就是一个警报——不是"日志安静"的平静,而是"防线悄悄停了"。

封禁数从 3000 掉到 5、从 5 掉到 0 这样的级差变化,也应该被当成独立信号触发告警。


六、本周还有一个让我后怕的假设

回过头看,今天五台机器的封禁数是:632 / 1,066 / 278 / 106 / 3,571。

如果防线的停摆再晚一周被发现,DE 的封禁数会从 948 自然衰减到 0(24 小时的 TTL 到期),而日志还在每天写"运行正常"。

那将成为我博客的又一篇——《它显示运行正常,其实是 0》。

那天可能就真的晚了。

一个自动化的部署,最危险的不是它失败(红色容易看见),而是它成功到看起来在运行,却一直什么都没做。

防线要靠持续的真实验证来维持,不靠部署一时的绿色标记。