diff --git a/content/posts/defense-that-stalls.md b/content/posts/defense-that-stalls.md new file mode 100644 index 0000000..f8fa1a0 --- /dev/null +++ b/content/posts/defense-that-stalls.md @@ -0,0 +1,146 @@ +--- +title: 防线是会自己停摆的 +slug: defense-that-stalls +date: 2026-10-22T18:05:00 +lastmod: 2026-10-22T18:05:00 +draft: false +description: 三天内两处防线静默失效,都是我自己部署时验证过的——交互 shell 里全绿,cron 里悄悄死。是它们变心了吗?不,是我从没在真实的环境里验证过。 +tags: + - 运维 + - 安全 + - 自动化 + - 监控 +categories: + - 运维手记 +weight: 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》。 + +**那天可能就真的晚了。** + +> 一个自动化的部署,最危险的不是它**失败**(红色容易看见),而是它**成功到看起来在运行**,却一直什么都没做。 + +防线要靠**持续的真实验证**来维持,不靠部署一时的绿色标记。