先把问题定性:不是防火墙拦了,是防护自己把正常访客挡在门外
上周给一台开了 fail2ban 的站点做压测,结果很怪:用浏览器手动访问一切正常,但换成自动化脚本连发几次请求,十秒之内就再也连不上了。SSH 登录进去一看,iptables -L 里已经多了三条针对我自己 IP 的 DROP 规则,而站点的 access.log 里什么都看不到——请求根本没到 Nginx,在防火墙层就被丢掉了。
这就是 fail2ban 这类工具最典型的副作用:它做得太好,好到你分不清"防护生效"和"误伤正常用户"。更麻烦的是它的误伤是静默的:日志里不会写"你的访客被拦了",访客只会看到连接超时,然后永远不再回来。个人站长装的站往往访问量不大,一天误伤十个 IP 里可能有三个是真实用户,但没有人会告诉你。
这篇文章讲的不是"如何安装 fail2ban"——这种教程已经够多了。它讲的是装上之后怎么确认它真的在按预期工作:哪些 IP 被拦了、为什么被拦、拦得对不对、误伤怎么恢复,以及规则和封禁状态在重启之后还在不在。
第一步:把当前封禁状态完整地摸清楚
排查的第一步永远是看现状。fail2ban 的状态分散在三个地方:它自己记的数据库、它下发给防火墙的规则、以及被它读的日志。三者不一致就是问题的根源。
# 1) fail2ban 自己的视图:哪些 jail 在跑、各自封了几个
fail2ban-client status
# 2) 单个 jail 的详情:当前封禁的 IP 列表、总封禁数、日志路径
fail2ban-client status sshd
fail2ban-client status nginx-req-limit
# 3) 防火墙实际生效的规则(这里才是真相)
iptables -L INPUT -n --line-numbers | head -40
# 新版本用 nftables 的机器:
nft list ruleset | grep -A3 'f2b'
# 4) 封禁记录(每次封禁/解封都有时间戳和原因)
tail -50 /var/log/fail2ban.log
# 或者 systemd 环境
journalctl -u fail2ban --since '24 hours ago' | grep -E 'Ban|Unban'这里有一个极其重要的细节:fail2ban-client status 显示的封禁列表不一定等于防火墙里的实际规则。后者才是真的。如果两边不一致,最常见的两种原因是:fail2ban 进程重启过而规则残留(导致 IP 永久被拦但 fail2ban 已经不管它了),或者有人手动改过 iptables(导致 fail2ban 以为拦了其实没拦)。这个不一致是新装环境排查的第一个方向,别跳过。
看封禁记录时,重点不是"封了几个",而是封禁原因的分布。下面这条命令能直接给出模式:
# 按 jail 统计最近 24 小时的封禁次数
journalctl -u fail2ban --since '24 hours ago' \
| grep -oE 'Ban [0-9.]+' | awk '{print $1}' | sort | uniq -c | sort -rn | head
# 看被封的 IP 有没有集中在某个网段(很可能是你自己或合作方的爬虫)
fail2ban-client status sshd | grep 'Banned IP list' \
| tr ' ' '\n' | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' | sort | uniq -c | sort -rn如果被封的 IP 集中在同一个 /24 网段,几乎可以肯定那不是攻击,而是某个机构出网的 NAT(公司、学校、运营商共享出口),你把整个办公室的人都拦了。如果被封的 IP 分散在全球各地而且每个只封一次,那是真的扫描。这个判断决定了下一步是"调整阈值"还是"保持原样"。
第二步:搞懂封禁的触发条件,别只看 maxretry
大多数人配 fail2ban 只调 maxretry(几次触发封禁)和 bantime(封多久),但真正决定误伤率的是第四个参数 findtime:在多长时间窗口内累计。默认常见的组合是 findtime = 10m、maxretry = 5,意思是十分钟内错五次就封。
这个默认值对 SSH 场景够用,但对 HTTP 场景就非常危险。举个具体的例子:一个博客页面里有一张图片加载失败(比如路径改过之后 404),浏览器重试、图片重试、CSS 里的背景图 404、favicon 404——这些在 access.log 里是同一个 IP 的连续 404。如果 jail 的规则是"findtime 内 5 次 404 就封",那么一个正常用户的浏览器完全可以在一秒内凑够五个 404,然后被你自己封掉 10 分钟。
还有一类更隐蔽的触发:错误页本身的资源在 404。用户访问一个已经不存在的文章,页面返回 404,但同时还会加载 CSS、JS、图片,其中如果有任何一个也是 404,那一个用户访问一次就是两三个 404 记录。看着像是"同一个 IP 反复请求不存在的资源",实际是十个真实用户。
所以针对 HTTP 类型的 jail,判断阈值时要按"一个正常用户一次访问最多产生多少条失败记录"来算,而不是按"攻击者多快能刷多少次"。我的经验值:单 IP 的 404 类 jail,maxretry 至少设到 20,findtime 设到 10 分钟;而登录失败类的 jail(登录接口返回 401/403)可以设 maxretry = 5、findtime = 10m,因为正常人不会连错五次密码。
[nginx-noscript]
enabled = true
port = http,https
filter = nginx-noscript
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 10m
bantime = 1h
[nginx-404-flood]
enabled = true
port = http,https
filter = nginx-404-flood
logpath = /var/log/nginx/access.log
maxretry = 25 # 注意:404 类必须放宽
findtime = 10m
bantime = 1h
action = iptables-multiport[name=404, port="http,https"]第三步:过滤器正则是误伤的最大来源
threshold 只是第二层保险,第一层是 filter 的正则。filter 写错有两种后果:一种是什么都匹配不上(防护等于没有),一种是把正常请求也匹配上(开始误伤)。后者常见得多,也危险得多。
# 直接拿真实日志测正则:这是最有效的调试方式
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-noscript.conf
# 输出会明确列出 Lines matched 的条数
# 如果匹配了上百条,先别急着高兴,把匹配的样例读一遍fail2ban-regex 的输出里最有价值的部分是"匹配行的样例"。我每次都会仔细看那几行,因为很多经典误伤就藏在那里:
误伤一:把 favicon 和 robots.txt 当成扫描。很多针对"探测常见漏洞文件"的 filter 会匹配 /.env、/wp-admin、/phpmyadmin 这类路径。但有些 filter 的正则写得太宽,把任何以点开头的路径都算进去了,于是 /.well-known/acme-challenge/(证书验证)也被当成攻击。证书一旦续期失败、站点 HTTPS 挂掉,这个损失比被扫描大得多。
误伤二:搜索引擎的爬虫被当成恶意抓取。搜索引擎的抓取频率远高于普通用户,如果对一个 IP 的请求频次做限制而不做 UA 或反向 DNS 白名单,第一个被封的往往就是搜索引擎的爬虫——而且封了之后它还会来,于是永久被封,你的收录直接停摆。
所以任何基于"请求频次"的 filter,都必须配白名单:
[DEFAULT]
# 白名单:本机、内网、监控、搜索引擎出口
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 192.168.0.0/16
# 搜索引擎的 IP 段会变,靠 IP 段不可靠,要靠反向 DNS 校验(见第七步)
# 临时做法:把已知的自己常用出口加进来
# ignoreip = ... 1.2.3.4
# 封禁时长策略:第一次短,重复犯加长
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1wbantime.increment 这一组参数非常值得开。它的逻辑是:同一个 IP 每被多封一次,封禁时长翻倍,直到上限。对比"每次固定封 10 分钟"的做法,它对真实攻击者更狠,对偶尔触发阈值的正常用户(只犯一次,只封 10 分钟)几乎没有额外伤害。这比一味拉长 bantime 更聪明。
第四步:确认规则会被持久化,重启后还在
这是最经典的坑,而且我见过不止一次线上事故就出在这:fail2ban 重启、服务器重启之后,iptables 规则清空,之前封的 IP 全部解封,攻击者立刻回来。原因是 fail2ban 默认在停止时会移除自己加的规则(这是设计行为),而 iptables 的规则本身默认不持久化。
# 检查 fail2ban 服务重启后规则是否还在(关键测试)
systemctl restart fail2ban
sleep 3
iptables -L INPUT -n | grep -c 'f2b'
# 如果输出 0,说明规则没有恢复
# 正确的处理:让 fail2ban 从自己的数据库恢复封禁
# fail2ban 会读 /var/lib/fail2ban/fail2ban.sqlite3 里的封禁记录
ls -lh /var/lib/fail2ban/fail2ban.sqlite3
# 手工确认数据库里的封禁记录还在
sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 \
"SELECT ip, jail, timeofban, bantime FROM bans ORDER BY timeofban DESC LIMIT 10;"如果重启后规则数为 0,说明 dbfile 配置有问题或者 fail2ban 的 action 没有正确执行 start。fail2ban-client status sshd 里的 Banned IP list 是从数据库读的,如果列表里有 IP 但 iptables 里没有,就是数据库和防火墙脱节了。修法是:fail2ban-client restart 之后立刻用 fail2ban-client status 对照 iptables -L,两边必须一致。
另外还有一层:如果机器同时开了 firewalld 和 iptables,或者开了 ufw,fail2ban 加的规则可能落在了错误的那一层,表现出来就是"fail2ban 说封了,但连接照旧能进来"。判断方法是看 fail2ban-client status sshd 里 Actions 那一行用了什么 action。默认的 iptables-multiport 只写 iptables,如果系统实际用的是 nftables 或 firewalld,就要切换到对应的 action(nftables-multiport、firewallcmd-rich-rules)。
第五步:误伤之后的恢复动作
发现某个 IP 被误封,正确的处理顺序是这样,别直接 iptables -D:
# 1) 先解封,让 fail2ban 自己把规则撤掉(直接改 iptables 会被 fail2ban 覆盖回来)
fail2ban-client set sshd unbanip 1.2.3.4
# 2) 如果是会反复触发的那种误伤(比如合作方的爬虫),加白名单
fail2ban-client set sshd addignoreip 1.2.3.4
# 但注意:addignoreip 只存在于当前运行内存,重启就丢
# 要持久化必须写进 jail.local 的 ignoreip
# 3) 确认规则真的撤了
iptables -L INPUT -n | grep 1.2.3.4
# 4) 查它为什么被封(找到对应的日志行,判断是 filter 误伤还是真的有问题)
grep '1.2.3.4' /var/log/nginx/access.log | tail -20第 2 步那个细节很重要:fail2ban-client set ... addignoreip 只改运行时的状态,写进配置文件才持久。我见过有人用命令加了几十个白名单,某次重启之后全部失效,误伤问题一夜之间全部复发,而人已经忘了加过什么。所以凡是白名单,一律直接改 /etc/fail2ban/jail.local 里的 ignoreip。
还有一个更彻底的思路:对于已知的、你自己控制不了的正常流量源,与其加白名单,不如直接让 filter 不匹配它。比如给合作方的爬虫设一个专属 UA,然后在 filter 里加一条排除规则。这样白名单列表不会无限膨胀。
第六步:用日志验证防护真的在起作用
配置完不等于生效。最可靠的验证方式是自己在受控条件下打一次:
# 自己找一个测试 IP(同事的、另一台云的),从它发起错误请求
for i in $(seq 1 6); do
curl -s -o /dev/null -w '%{http_code} ' -H 'User-Agent: test-breaker' \
https://www.example.com/notexist-$i
done
# 立刻查是否被封
fail2ban-client status nginx-404-flood | grep 'Banned IP list'
iptables -L INPUT -n | grep '<测试IP>'
# 确认封禁后的表现:应该是超时而不是 403/404
curl -s -m 5 -o /dev/null -w '%{http_code}\n' https://www.example.com/最后一条检查很关键:被封之后的表现应该是连接超时(curl 返回 000,等待 5 秒),而不是返回 403 或 444。返回 403 说明是应用层在拒绝,不是防火墙;返回 444 说明是 Nginx 的 return 444 规则生效了,也不是 fail2ban。很多"fail2ban 没生效"的判断失误就出在这里——其实防护根本没在防火墙层,而是在别的地方。
第七步:白名单里的搜索引擎该怎么处理
这一点值得单拎出来讲,因为它是最容易造成长期损失的误伤。搜索引擎的爬虫 IP 经常变,靠固定 IP 段做白名单会逐渐失效。可靠的做法是反向 DNS 校验:查请求来源 IP 的 PTR 记录,看它是不是属于搜索引擎的域名后缀,并且再正向解析一次,确认解回来的是同一个 IP(防止有人伪造 PTR)。
# 手工验证一个疑似爬虫 IP
dig +short -x 66.249.66.1
# 期望输出类似 crawl-66-249-66-1.googlebot.com.
dig +short crawl-66-249-66-1.googlebot.com
# 期望解回 66.249.66.1,对不上就是伪造
# Nginx 层排除已知爬虫并单独记日志(不参与封禁)
map $http_user_agent $is_verified_bot {
default 0;
"~*(googlebot|bingbot|baiduspider|yandex)" 1;
}
# 在 fail2ban 的 filter 里排除这些 UA反过来,有些恶意爬虫会伪装成 Googlebot 的 UA。所以判定不能只看 UA,要看 UA + PTR 双重验证。Nginx 的 realip 模块配合日志可以把这个判断记录下来,具体做法可以扩展 log_format 额外输出一个"是否验证过的爬虫"字段,之后在统计时按这个字段过滤,就不会把伪装爬虫的流量算进正常流量。
第八步:把它变成一个可以每月复核的习惯
防护规则不是配好就一劳永逸的东西。业务在变、攻击手法在变、正常用户的浏览器行为也在变,阈值需要定期复核。我给自己定的月度检查动作只有五条,花五分钟:
fail2ban-client status看每个 jail 的总封禁数,如果某个 jail 一天封上百个,阈值大概率太严。- 看被封 IP 的网段分布,如果出现集中的 /24,多半是误伤了某个机构出口。
grep 'Ban' /var/log/fail2ban.log | tail -100,抽十条看原因,判断 filter 有没有写歪。- 确认白名单和
jail.local一致,没有只存在于运行时的临时白名单。 - 重启一次 fail2ban,确认规则能恢复、封禁列表能从数据库读出。
第 5 条看着麻烦,但它是唯一能提前发现"重启后防护全部失效"这种问题的手段。与其等到某次服务器重启之后被攻击,不如每个月主动重启一次确认。
小结:好防护的标准是"拦得住攻击,也放得过真人"
fail2ban 这类工具的价值不在参数调得多凶猛,而在于判据是否精确。一个把 maxretry 设成 3 的站,可能把所有正常用户的浏览器都拦在门外;一个把 filter 写歪的站,可能把证书验证和搜索引擎一起封掉。这两种情况和"没装防护"在业务上的结果是一样的——甚至更差,因为前者至少你知道自己没防护,后者你会以为自己很安全。
所以配置完之后必须回到数据上做验证:封禁列表里的 IP 分布对不对,fail2ban-regex 匹配的日志行是不是真的是攻击,被封之后看到的是超时还是 403,重启之后规则还在不在。这四个问题的答案都对了,这套防护才算真的落地。