最近我的网站服务器接连问题不断,先是PHP官方发了紧急安全更新,但宝塔软件商店迟迟不给升级按钮;后是面板自带的Fail2ban插件突然就挂了,点启动也没反应。
一、PHP 紧急安全更新
2026年10月21日,PHP官方发了个紧急安全更新,修了两个严重漏洞:CVE-2026-109618 和 CVE-2026-109619。两个 CVSSv4.0都是 9.2(严重),攻击者不用登录就能远程代码执行(RCE)。服务器直接暴露公网的话,这种洞得第一时间堵。
宝塔官方说已经同步了PHP 8.2–8.5的更新,但我这台面板软件商店里,PHP8.5.9压根没出现更新按钮。强迫症患者等不了官方通道,直接改安装脚本里的版本号,手动触发更新。
操作就两步:
- 编辑
/www/server/panel/install/php.sh,找到php_85=8.5.9这一行,把版本号改成已经发布的安全版本php_85=8.5.12。 - 保存后,终端执行:
bash /www/server/panel/install/php.sh update 8.5
注意:手动改版本号属于绕过官方升级通道的应急办法,只在面板没推更新、且确认目标版本确实存在时用。改完记得去软件商店核对一下 PHP 实际版本号,别把脚本写错导致编译失败。
PHP补丁打完,应用层的 RCE 洞算是堵上了。但服务器安全不是补一个洞就完事——外面 SSH 爆破、网站 CC 这些 7×24 不停的骚扰,还得另一件装备Fail2ban顶着。
二、Fail2ban
Fail2ban 是一个用 Python 写的开源入侵防御框架,核心思路就一句话:盯着日志文件,发现某IP短时间内触发了可疑模式(比如反复 SSH 登录失败),就调用防火墙把它临时封掉。
2.1 它解决了什么问题
服务器暴露在公网后,SSH(22 端口)、网站(80/443)几乎无时无刻不在被扫描和爆破:
- SSH:自动化脚本用常见用户名/弱密码字典疯狂试登录;
- 网站:爬虫、CC 攻击、漏洞扫描器高频请求。
靠人工看日志、手敲 iptables 封 IP,既来不及也不可持续。Fail2ban 把这些变成自动化:规则命中即封,过期自动解封。
2.2 它是怎么工作的
Fail2ban 由三个核心概念组成:
| 概念 | 作用 | 举例 |
|---|---|---|
| Jail(监狱) | 一个被监控的”服务+规则”组合,是 Fail2ban 的最小管理单元 | sshd(监控 SSH)、wanghao.me-cc(监控网站 CC) |
| Filter(过滤器) | 一组正则表达式,从日志里匹配”这是一次攻击”的行 | 匹配 Failed password for ... from <IP> |
| Action(动作) | 命中后执行的操作,通常是调用防火墙封禁 IP | iptables / nftables / ufw 加一条拒绝规则 |
一次典型的封禁流程:
- Fail2ban 通过 backend(后端)读取日志——可以是直接轮询日志文件(
polling),也可以从 systemd-journald 读(systemd); - Filter 正则匹配到
maxretry次失败(比如 5 次/10 分钟); - Action 调用防火墙把该 IP 加入黑名单,封
bantime(比如 1 小时); - 到期后自动移除规则,恢复正常访问。
2.3 几个关键术语(后文会反复用到)
- backend:Fail2ban 获取日志的方式。
systemd依赖 Python 的systemd模块读 journal;polling则是定时读取指定的日志文件(logpath)。这两者不能混用——这也是本文修复的核心矛盾点。 - logpath:
polling后端必须配置的日志文件路径(如/var/log/auth.log)。若文件不存在或为空,fail2ban 要么起不来,要么起得来也读不到攻击。 - banaction:实际执行封禁的防火墙工具,常见
iptables、nftables、ufw。 - jail.local / jail.d/:配置文件。
jail.conf是默认,jail.local和jail.d/*.local是用户/插件覆盖层,后读取的文件覆盖先读取的同段同键。
2.4 它和宝塔面板的关系
本文所说的Fail2ban是宝塔面板自带的插件版本。它本质上是官方Fail2ban套了一层面板管理UI,并把可执行文件和 Python 环境塞进了宝塔自己的 pyenv(/www/server/panel/pyenv)。这带来一个隐患:一旦宝塔升级这个 pyenv,Fail2ban 的模块和依赖(如 systemd 扩展)就可能被”连坐”破坏——这正是本文要修的故障源头。
三、故障现象
Fail2ban 服务启动不了,具体表现:
systemctl is-active fail2ban
# failed
/www/server/panel/pyenv/bin/fail2ban-client status
# ERROR Failed to access socket path: /www/server/panel/plugin/fail2ban/fail2ban.sock. Is fail2ban running?
在宝塔面板里点”启动/重启”同样无效。因为fail2ban启动不了,SSH暴力破解、网站CC/扫描都不会被自动封 IP,等于裸奔。
journalctl -u fail2ban.service 里能看到类似:
fail2ban.service: Main process exited, code=exited, status=255/EXCEPTION
fail2ban.service: Failed with result 'exit-code'.
以及更早的线索:
ERROR Failed during configuration: While reading from '/etc/fail2ban/jail.local' [line 10]: option 'backend' in section 'sshd' already exists
注意这一行的 failed since 2026-09-19 时间戳——它指向最早的根因,而不是你最近的一次操作。
四、根因链
真正的原因不是 Fail2ban 本身,而是宝塔面板升级了它自带的 Python 环境(pyenv),引发了一连串破坏。
4.1 pyenv 升级的连锁反应
宝塔某次升级把面板 Python 环境从 pyenv313(Python 3.13.x 之前的路径)删除、重建为 pyenv。这一步同时造成了四个问题:
| # | 破坏点 | 直接后果 |
|---|---|---|
| a | systemd unit 的 ExecStart/Stop/Reload 仍指向已删除的 pyenv313/bin/fail2ban-server |
启动报 203/EXEC(找不到可执行文件) |
| b | fail2ban 的 Python 模块没有迁移进新环境的 site-packages | ModuleNotFoundError: No module named 'fail2ban' |
| c | 新环境缺少 systemd 这个 Python C 扩展 |
sshd jail 的 backend=systemd 初始化失败:No module named 'systemd' |
| d | 宝塔插件自身还会 spawn 一个孤儿 --async -b 进程占着插件 socket |
干扰判断,让人以为”服务在跑但其实没跑” |
其中 (c) 是核心:fail2ban 的 sshd jail 默认用 systemd 后端读 journal 日志,而新 pyenv 里没有 systemd 模块,于是 sshd jail 初始化失败 → 整个服务以 exit 255 退出。
4.2 配置层的隐藏根因(第二台机才暴露)
第一台公司网站服务器我用AI全程自动帮我解决了auto/改 polling 就解决了,但第二台服务器小王子网站我决定手动解决(当然也是通过AI再手动复制命令操作),无奈反复失败,最后挖出了更深的配置问题:
/etc/fail2ban/jail.d/sshd.local覆盖了 jail.local。 fail2ban 读取配置的顺序是jail.conf→jail.d/*.conf→jail.local→jail.d/*.local,后面的文件覆盖前面的同段同键。这个sshd.local里写了[sshd] backend = systemd,排在 jail.local 之后,于是你改了 jail.local 的backend=polling也不生效——运行时 sshd 拿到的还是 systemd。- 这也是为什么
fail2ban-client -t(配置自检)能过,但运行时 sshd 仍是 systemd:-t不报跨文件覆盖冲突,只报语法/重复项。
- 这也是为什么
- jail.local 的
[sshd]段有重复的backend行(陈年 bug,从 2026-09-19 就在)。两条同键 →-t报option 'backend' in section 'sshd' already exists。
4.3 改成 polling 后端后的”预期内”新错误
把后端从 systemd 改成 polling(轮询日志文件)后,fail2ban 必须盯一个真实存在的日志文件。但 Debian 12 默认 sshd 只写 journal、不写文件,于是冒出:
ERROR Have not found any log file for 'sshd' jail.
这不是意外,是改后端后的必然下一步——必须给 sshd 指定一个落盘日志路径。
五、详细的修复方法
下面按”先恢复二进制、再修配置、最后补日志”的顺序。所有命令在无反斜杠前提下给出(见第六节坑点),可直接在服务器终端粘贴。
步骤 0:准备好变量(确定 pyenv 版本与备份目录)
# 找到升级前的 pyenv 备份目录(时间戳不同,自行 ls 确认)
ls -d /www/server/panel/pyenv* # 例如 pyenv(新)和 pyenv_20261021_211619(旧备份)
# 确认新 pyenv 的 python 与 site-packages 路径
ls /www/server/panel/pyenv/bin/python3
ls /www/server/panel/pyenv/lib/python3.13/site-packages/ | head
步骤 1:恢复 fail2ban 模块(解决 4.1-b)
把升级前备份里的 fail2ban 包和 egg-info 拷进新环境:
OLD=/www/server/panel/pyenv_20261021_211619/lib/python3.7/site-packages # 用你实际的旧路径
NEW=/www/server/panel/pyenv/lib/python3.13/site-packages
cp -r $OLD/fail2ban $NEW/
cp -r $OLD/fail2ban-1.1.1.dev1*.egg-info $NEW/
# 验证能导入
/www/server/panel/pyenv/bin/python3 -c "import fail2ban; print(fail2ban.__version__)"
# 期望输出:1.1.1.dev1
注意:一定要用升级当天的备份目录(如
pyenv_20261021_211619),不要用一个更老的(如pyenv_20260807_205158),否则版本不匹配。
步骤 2:修复 systemd unit 路径(解决 4.1-a)
# 先看当前 unit 指向哪
grep -n 'pyenv' /lib/systemd/system/fail2ban.service
# 把残留的 pyenv313 改成 pyenv(三处 Exec* 一起改)
sed -i 's#pyenv313#pyenv#g' /lib/systemd/system/fail2ban.service
systemctl daemon-reload
步骤 3:重链 fail2ban-python(若它是坏的符号链接)
ls -l /www/server/panel/pyenv/bin/fail2ban-python
# 若指向已删除的 pyenv313,重建:
ln -sf /www/server/panel/pyenv/bin/python3 /www/server/panel/pyenv/bin/fail2ban-python
步骤 4:全局强制后端为 polling(解决 4.1-c + 4.2)
用一个 Python 脚本把所有配置文件里的 backend 强制改成 polling,并自动备份。这是关键——无论覆盖来自 jail.conf、jail.local 还是 jail.d,最终生效的都只会是 polling:
python3 - <<'PY'
import os, glob, re, shutil, datetime
stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
files = ['/etc/fail2ban/jail.conf'] \
+ sorted(glob.glob('/etc/fail2ban/jail.d/*.conf')) \
+ ['/etc/fail2ban/jail.local'] \
+ sorted(glob.glob('/etc/fail2ban/jail.d/*.local'))
changed = []
for f in files:
if not os.path.exists(f):
continue
lines = open(f).read().split('\n')
out = []; cur = None; did = False
for l in lines:
s = l.strip()
m = re.match(r'^\[(.+?)\]\s*$', s)
if m:
cur = m.group(1)
if cur == 'DEFAULT' and re.match(r'^sshd_backend\s*=', s, re.I):
l = 'sshd_backend = polling'; did = True
if re.match(r'^backend\s*=', s, re.I):
l = 'backend = polling'; did = True
out.append(l)
if did:
shutil.copy(f, f + '.bak-' + stamp)
open(f, 'w').write('\n'.join(out))
changed.append(f)
print("Patched files:")
for f in changed:
print(" ", f)
PY
步骤 5:去重 jail.local 的重复 backend(解决 4.2 的 “already exists”)
如果步骤 4 之前已经有重复 backend 行,或步骤 4 没覆盖到,用下面这段零反斜杠脚本清理 [sshd] 段内的重复 backend(BT-Panel 终端会吞 \[,所以这里不用正则):
python3 - <<'PY'
p = '/etc/fail2ban/jail.local'
lines = open(p).read().split('\n')
out = []; cur = None; seen = False
for l in lines:
s = l.strip()
if s.startswith('[') and s.endswith(']'):
cur = s[1:-1]; seen = False
if cur == 'sshd' and s.lower().startswith('backend'):
if seen:
continue
l = 'backend = polling'; seen = True
out.append(l)
open(p, 'w').write('\n'.join(out))
print(open(p).read())
PY
步骤 6:给 sshd 指定真实日志文件(解决 4.3)
改完后端必须给 sshd 一个落盘日志。Debian/Ubuntu 用 /var/log/auth.log,CentOS/RHEL/阿里云老镜像用 /var/log/secure。下面脚本自动判断并注入到 sshd.local(覆盖最优先,确保生效),同时去重已有的 logpath:
python3 - <<'PY'
import os, re
cands = ['/var/log/auth.log', '/var/log/secure']
logf = next((c for c in cands if os.path.exists(c)), None)
if not logf:
try:
rel = open('/etc/os-release').read().lower()
logf = '/var/log/secure' if any(k in rel for k in ('centos','alinux','rhel','fedora','anolis')) else '/var/log/auth.log'
except Exception:
logf = '/var/log/auth.log'
open(logf, 'a').close() # 确保文件存在
p = '/etc/fail2ban/jail.d/sshd.local'
lines = open(p).read().split('\n')
out = []; cur = None; seen = False
for l in lines:
s = l.strip()
if s.startswith('[') and s.endswith(']'):
cur = s[1:-1]; seen = False
if cur == 'sshd' and s.lower().startswith('logpath'):
if seen:
continue
l = 'logpath = ' + logf; seen = True
if cur == 'sshd' and s.lower().startswith('backend'):
l = 'backend = polling'
out.append(l)
if not seen:
for i, l in enumerate(out):
if l.strip() == '[sshd]':
out.insert(i + 1, 'logpath = ' + logf); break
open(p, 'w').write('\n'.join(out))
print('--- sshd.local ---')
print(open(p).read())
PY
步骤 7:确认 rsyslog 真的把 auth 写进日志(否则封不了 IP)
polling 后端依赖日志文件里有内容。如果文件是空的,fail2ban 能启动但永远读不到爆破记录、不会封 IP。
# rsyslog 是否在跑
systemctl status rsyslog --no-pager | head -3
# 用 logger 发一条测试日志,验证落盘
touch /var/log/auth.log
logger -p auth.info "fail2ban rsyslog test $(date +%s)"
sleep 1
tail -n 3 /var/log/auth.log
# 应能看到上面那行 "fail2ban rsyslog test ..."
如果 tail 看不到,说明 rsyslog 没把 auth 写进该文件,需要补规则:
# 确认是否已有规则
grep -r 'auth.log' /etc/rsyslog.conf /etc/rsyslog.d/ 2>/dev/null
# 没有就补一条
echo 'auth,authpriv.* /var/log/auth.log' > /etc/rsyslog.d/49-fail2ban-auth.conf
systemctl restart rsyslog
步骤 8:清理残留进程并重启
# 杀掉可能残留的孤儿 fail2ban-server(注意用 [f] 避免 pkill 误杀自己)
pkill -f '[f]ail2ban-server'; sleep 1
# 清掉 systemd 的启动失败计数(避免 "Start request repeated too quickly" 限速)
systemctl reset-failed fail2ban
# 确保 socket 目录存在
mkdir -p /www/server/panel/plugin/fail2ban /run/fail2ban
systemctl restart fail2ban
sleep 3
systemctl is-active fail2ban
六、关键坑点(血泪总结)
- BT-Panel 的 Web 终端会吞掉反斜杠。 粘贴含
\[的 Python 正则(如re.match(r'^\[sshd\]'))时,\[会被破坏,正则永远匹配不到段名,去重/注入逻辑全部失效,反而反复追加重复行。- ❌ 不要用
re.match(r'^\[(.+?)\]') - ✅ 改用
s.startswith('[') and s.endswith(']')判断段名,全程零反斜杠。
- ❌ 不要用
fail2ban-client -t不是万能的。 它只做配置语法/重复项自检,不检测跨文件覆盖冲突,也不检测运行时 jail 后端初始化失败。必须结合前台运行 +journalctl看真实错误:shtimeout 10 /www/server/panel/pyenv/bin/fail2ban-server -xf \ -s /www/server/panel/plugin/fail2ban/fail2ban.sock \ -p /run/fail2ban/fail2ban.pid start 2>&1 | head -40- backend 的优先级:section 级覆盖 DEFAULT 级。 在
[DEFAULT]里写backend=polling会被 jail.conf/jail.d 里[sshd] backend=systemd覆盖。必须写进[sshd]段本身,或干脆全局强制(步骤 4)。 - systemd 启动速率限制。 连续失败太多次会触发
Start request repeated too quickly,此时restart直接 failed。先用systemctl reset-failed fail2ban清计数,必要时等 1~2 分钟再重启。 pkill -f 'fail2ban-server'会自杀。 该模式会匹配到当前 shell 命令行自身。用pkill -f '[f]ail2ban-server'(加方括号)规避。- pip 装不了 fail2ban。 PyPI 只发 sdist,新 pip 直接报
from versions: none。正确做法是从 pyenv 升级前的备份目录拷模块,而不是尝试 pip 安装。
七、验证是否真的修好
B=/www/server/panel/pyenv/bin/fail2ban-client
# 1. 配置自检应无报错
$B -t
# 期望:OK: configuration test is successful
# 2. 服务应 active
systemctl is-active fail2ban
# 期望:active
# 3. 列出所有 jail(两台机分别是 3 / 4 个)
$B status
# 期望 jail list 含 sshd 以及你的网站 jail(如 wanghao.me-cc / wanghao.me-scan)
# 4. 确认 sshd 后端确实是 polling(不是 systemd)
$B get sshd backend
# 期望:polling
# 5. 确认各 jail 的 File list 指向正确日志
$B status sshd
$B status wanghao.me-cc
# 6. 开机自启确认
systemctl is-enabled fail2ban
# 期望:enabled
最终成功时,status 会列出全部 jail,例如:
Status
|- Number of jail: 3
`- Jail list: sshd, wanghao.me-cc, wanghao.me-scan
八、收尾与注意事项
- 清理本次产生的备份文件(确认稳定后):
sh
ls -l /etc/fail2ban/jail.local.bak* /etc/fail2ban/jail.conf.bak* /etc/fail2ban/jail.d/sshd.local.bak* 2>/dev/null rm -f /etc/fail2ban/jail.local.bak* /etc/fail2ban/jail.conf.bak* /etc/fail2ban/jail.d/sshd.local.bak*另外第一台机若保留着
.env.bak.before-pwchange.*(含旧数据库口令),稳定后也建议删除。 - 宝塔面板”重启插件”可能还原配置。 宝塔的 Fail2ban 插件在重载时有可能重新生成
jail.d/sshd.local,把backend=polling/logpath=/var/log/auth.log还原成默认的backend=systemd。如果哪天 fail2ban 又起不来,先tail /var/log/fail2ban.log看是不是又回到No module named 'systemd',是的话重跑步骤 5~8 即可。 - 治本建议。 这类故障的根子是被宝塔面板升级”连坐”了自带的 pyenv。长期看,可以考虑让 fail2ban 用系统自带 Python(而不是宝塔的 pyenv),或把关键配置(后端、日志路径)在面板里也固化,减少被自动覆盖的概率。
正在等待第一条评论