服务器安全维护:PHP补丁与Fail2ban修复

最近我的网站服务器接连问题不断,先是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压根没出现更新按钮。强迫症患者等不了官方通道,直接改安装脚本里的版本号,手动触发更新。

操作就两步:

  1. 编辑 /www/server/panel/install/php.sh,找到 php_85=8.5.9 这一行,把版本号改成已经发布的安全版本 php_85=8.5.12。
  2. 保存后,终端执行:
  3. sh
    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 加一条拒绝规则

一次典型的封禁流程:

  1. Fail2ban 通过 backend(后端)读取日志——可以是直接轮询日志文件(polling),也可以从 systemd-journald 读(systemd);
  2. Filter 正则匹配到 maxretry 次失败(比如 5 次/10 分钟);
  3. Action 调用防火墙把该 IP 加入黑名单,封 bantime(比如 1 小时);
  4. 到期后自动移除规则,恢复正常访问。

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 服务启动不了,具体表现:

sh
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 里能看到类似:

sh
fail2ban.service: Main process exited, code=exited, status=255/EXCEPTION
fail2ban.service: Failed with result 'exit-code'.

以及更早的线索:

sh
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、不写文件,于是冒出:

sh
ERROR  Have not found any log file for 'sshd' jail.

这不是意外,是改后端后的必然下一步——必须给 sshd 指定一个落盘日志路径。

五、详细的修复方法

下面按”先恢复二进制、再修配置、最后补日志”的顺序。所有命令在无反斜杠前提下给出(见第六节坑点),可直接在服务器终端粘贴。

步骤 0:准备好变量(确定 pyenv 版本与备份目录)

sh
# 找到升级前的 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 拷进新环境:

sh
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)

sh
# 先看当前 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(若它是坏的符号链接)

sh
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:

sh
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 终端会吞 \[,所以这里不用正则):

sh
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:

sh
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。

sh
# 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 写进该文件,需要补规则:

sh
# 确认是否已有规则
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:清理残留进程并重启

sh
# 杀掉可能残留的孤儿 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

六、关键坑点(血泪总结)

  1. BT-Panel 的 Web 终端会吞掉反斜杠。 粘贴含 \[ 的 Python 正则(如 re.match(r'^\[sshd\]'))时,\[ 会被破坏,正则永远匹配不到段名,去重/注入逻辑全部失效,反而反复追加重复行。
    • ❌ 不要用 re.match(r'^\[(.+?)\]')
    • ✅ 改用 s.startswith('[') and s.endswith(']') 判断段名,全程零反斜杠。
  2. fail2ban-client -t 不是万能的。 它只做配置语法/重复项自检,不检测跨文件覆盖冲突,也不检测运行时 jail 后端初始化失败。必须结合前台运行 + journalctl 看真实错误:
    sh
    timeout 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
  3. backend 的优先级:section 级覆盖 DEFAULT 级。 在 [DEFAULT] 里写 backend=polling 会被 jail.conf/jail.d 里 [sshd] backend=systemd 覆盖。必须写进 [sshd] 段本身,或干脆全局强制(步骤 4)。
  4. systemd 启动速率限制。 连续失败太多次会触发 Start request repeated too quickly,此时 restart 直接 failed。先用 systemctl reset-failed fail2ban 清计数,必要时等 1~2 分钟再重启。
  5. pkill -f 'fail2ban-server' 会自杀。 该模式会匹配到当前 shell 命令行自身。用 pkill -f '[f]ail2ban-server'(加方括号)规避。
  6. pip 装不了 fail2ban。 PyPI 只发 sdist,新 pip 直接报 from versions: none。正确做法是从 pyenv 升级前的备份目录拷模块,而不是尝试 pip 安装。

七、验证是否真的修好

sh
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,例如:

sh
Status
|- Number of jail:      3
`- Jail list:   sshd, wanghao.me-cc, wanghao.me-scan

八、收尾与注意事项

  1. 清理本次产生的备份文件(确认稳定后):
    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.*(含旧数据库口令),稳定后也建议删除。

  2. 宝塔面板”重启插件”可能还原配置。 宝塔的 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 即可。
  3. 治本建议。 这类故障的根子是被宝塔面板升级”连坐”了自带的 pyenv。长期看,可以考虑让 fail2ban 用系统自带 Python(而不是宝塔的 pyenv),或把关键配置(后端、日志路径)在面板里也固化,减少被自动覆盖的概率。
那年 • 今日
老王于2026-10-22 21:05发布在软硬兼施
楚客追梦情难休,万里追寻志未酬。

赞助 点赞 0

正在等待第一条评论

头像预览

发表评论

您的邮箱地址不会被公开, 必填项已用 * 标注。