宝塔面板不该以 root 裸奔在公网:VPS 上把管理面压进 WireGuard + cgroup v2 的分层架构设计

先说结论:宝塔面板在 VPS 上的真实身份,不是一个"网站管理工具",而是一个常驻 root 权限、能写 crontab、能改 iptables、能重写 Nginx 配置的编排器。绝大多数人的用法是把它 8888 端口直接怼在公网,再配个复杂路径当密码用。这套架构设计的核心目标只有一个:保留宝塔的编排能力,把它的爆炸半径压到最小

一、先摸清宝塔在系统里到底占了哪些特权面

动手之前必须知道它在哪些地方落了根。登录机器,跑这几条:

ss -lntp | grep -i bt
ps -eo pid,user,%cpu,%mem,cmd | grep -E 'BT-Panel|BT-Task'
cat /proc/$(pgrep -f BT-Panel | head -1)/cgroup
crontab -l | tail -20
ls -l /www/server/panel/data/{port.pl,admin_path.pl,default.db}

典型输出会告诉你几件事:

  • /www/server/panel/BT-Panel 是一个 gevent + pywsgi 的常驻 Python 进程,以 root 身份监听 0.0.0.0:8888,它不接受任何 bind 地址配置——port.pl 只能改端口号,改不了监听地址。
  • 另有一个 BT-Task 进程负责计划任务调度,它最终会把任务写进 root 的 crontab(/var/spool/cron/root),也就是说面板里一条"计划任务"等于给你系统塞了一条 root 定时执行
  • 防火墙插件直接操作 iptables 的 INPUT 链,每次你在面板里点"同步规则",它会重建自己的链。你手工加的规则大概率活不过下一次点击。
  • 站点配置被面板托管在 /www/server/panel/vhost/nginx/*.conf,改站点 SSL、改伪静态,面板都会整文件重写。
  • 面板数据是 sqlite:/www/server/panel/data/default.db,WAL 模式下直接 cp 备份出来的文件可能是坏的。

把这五点记住,后面的分层就是围绕它们做的。

二、架构总览:把一台 VPS 切成四个平面

  • 管理面:面板 + SSH,只允许从 WireGuard 隧道进入,公网零暴露。
  • 编排面:宝塔写配置、跑计划任务,但被 cgroup 限死资源上限,且不许碰你的自定义配置目录。
  • 运行面:Nginx / PHP-FPM / MySQL,按站点分 UID、分 socket、分 open_basedir。
  • 恢复面:面板元数据与业务数据分开备份,恢复顺序固定。

四层之间靠"面板不认得的目录"和"面板管不到的 hook"来解耦,这是整套设计的关键。

三、管理面收口:8888 永不落公网

起一个 wg0,服务端就在这台 VPS 上:

# /etc/wireguard/wg0.conf
[Interface]
Address = 10.66.66.1/24
ListenPort = 51820
PrivateKey = <server.key>

[Peer]
PublicKey = <laptop.pub>
AllowedIPs = 10.66.66.2/32

客户端 AllowedIPs 只写 10.66.66.0/24,别写成 0.0.0.0/0,否则你自己访问业务站的流量也被绕进隧道,非常难排查。我手上这台轻云互联的机器带一个内网地址,wg 的 ListenPort 甚至可以直接只在内网网卡上开,连 UDP 51820 都不用露在公网,省了一层端口暴露面。

然后是关键的一步:不要用宝塔防火墙去封 8888,用独立的 nftables 表,并且抢在 iptables 之前执行

# /etc/nftables.d/guard.nft
table inet guard {
  chain input {
    type filter hook input priority -200; policy accept;
    iifname "wg0" tcp dport { 22, 8888 } ct state new counter accept
    iifname != "wg0" tcp dport { 8888 } ct state new counter drop
    iifname != "wg0" tcp dport { 22 } ct state new counter drop
  }
}

为什么是 priority -200:iptables 的 filter 表 hook 优先级是 0,宝塔防火墙无论怎么重建规则都在 0 这一层。你在 -200 层先 drop,包根本走不到它的链,它怎么同步都不会把你的收口规则冲掉。注意如果你的 iptables 是 nft 后端(Debian 10+/CentOS 8+ 默认),两者共享同一个 nft 表空间,优先级语义依然成立。

再补两条:面板安全入口路径 /www/server/panel/data/admin_path.pl 改掉默认值;面板 SSL 打开,哪怕只在隧道内访问也开——防止隧道内中间人抓到 bcrypt 之前的明文口令。

四、编排面降权:用 cgroup v2 给 BT-Panel 戴镣铐

宝塔是 init.d 脚本启动的(/etc/init.d/bt),不走 systemd unit,所以默认它和你的 MySQL、PHP-FPM 平起平坐。面板一旦被扫描器怼到,Python 进程打满一个核,你的 502 就来了。

先确认 cgroup v2:

stat -fc %T /sys/fs/cgroup
# 期望输出 cgroup2fs

然后写一个 slice 把它框起来:

# /etc/systemd/system/panel.slice
[Slice]
CPUWeight=20
IOWeight=20
MemoryHigh=600M
MemoryMax=1G
MemorySwapMax=0
TasksMax=512
# /etc/systemd/system/bt-panel.service
[Unit]
Description=BaoTa Panel (contained)
After=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
Slice=panel.slice
KillMode=control-group
ExecStart=/etc/init.d/bt start
ExecStop=/etc/init.d/bt stop

[Install]
WantedBy=multi-user.target

然后关掉 init.d 的开机自启,改由这个 unit 接管:

systemctl disable bt 2>/dev/null
update-rc.d bt disable 2>/dev/null || chkconfig bt off
systemctl daemon-reload
systemctl enable --now bt-panel
systemctl show -p ControlGroup bt-panel

CPUWeight=20 对默认 100 的 system.slice 意味着:面板和业务抢 CPU 时只能拿到约 1/6 的份额。MemorySwapMax=0 是为了防止面板进程被换到 swap 上之后,业务内存回收更激进——这个坑我在别的机器上踩过,面板不崩,业务先 OOM。

还有一个容易忽略的点:宝塔面板自带在线更新,更新后会自己调 /etc/init.d/bt restart 重启,新 fork 出来的进程虽然通常还落在 unit cgroup 里,但如果你动过 KillMode 或用了 systemd-run,就可能脱管。加个守卫定时器:

#!/bin/bash
# /usr/local/bin/bt-cgroup-guard.sh
SLICE_CG=$(systemctl show -p ControlGroup --value bt-panel)
for pid in $(pgrep -f 'BT-Panel|BT-Task'); do
  cur=$(cat /proc/$pid/cgroup | awk -F: '{print $3}')
  [[ "$cur" == "$SLICE_CG" ]] || {
    echo $pid >> /sys/fs/cgroup${SLICE_CG}/cgroup.procs
    logger -t bt-guard "re-attached pid $pid to ${SLICE_CG}"
  }
done
# /etc/systemd/system/bt-guard.timer
[Unit]
Description=Re-attach BaoTa panel to its cgroup

[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
AccuracySec=30s

[Install]
WantedBy=timers.target

五、运行面隔离:一站一 UID、一 pool、一 socket

宝塔默认所有站点共用一个 PHP-FPM pool(www 用户),这是最要命的一环——一个站被拿下,/www/wwwroot 下所有站点的 wp-config.php 都能读。

利用一个宝塔不会去动的路径:php-fpm.d/ 目录里它只维护 www.conf,你自己丢进去的 pool 文件会跟着 include=/www/server/php/74/etc/php-fpm.d/*.conf 一起被加载。

# /www/server/php/74/etc/php-fpm.d/site_a.conf
[site_a]
user = site_a
group = site_a
listen = /www/server/php/74/var/run/site_a.sock
listen.owner = www
listen.group = www
listen.mode = 0660
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.max_requests = 500
request_terminate_timeout = 60s
php_admin_value[open_basedir] = /www/wwwroot/site_a:/tmp
php_admin_value[sys_temp_dir] = /www/wwwroot/site_a/.tmp
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
php_admin_value[upload_tmp_dir] = /www/wwwroot/site_a/.tmp
groupadd -r site_a
useradd -r -g site_a -d /www/wwwroot/site_a -s /sbin/nologin site_a
mkdir -p /www/wwwroot/site_a/.tmp
chown -R site_a:site_a /www/wwwroot/site_a
chmod 750 /www/wwwroot/site_a /www/wwwroot/site_a/.tmp
chmod 640 /www/wwwroot/site_a/wp-config.php
# 让 nginx worker 能读,但不给写
usermod -aG site_a www

Nginx 侧把 fastcgi_pass 指到新 socket,并且别去改面板生成的 conf,写进你自己的 include 目录(下一节讲)。这样 Nginx 是 www 用户只读,PHP-FPM 是 site_a 用户读写,两层用户不同,一个站的 PHP 后门拿不到另一个站的文件。

顺带提一句 MySQL:宝塔建库默认给的是全库权限的 site_a 账号且允许 localhost。花两分钟改成 GRANT ALL ON site_a.* TO 'site_a'@'localhost',别留 *.*

六、配置治理:面板会重写的东西,一律别手改

这是整套架构里最容易被忽视、也最容易在生产事故中出现的一层。规则很简单:

  • /www/server/panel/vhost/nginx/*.conf:面板的领地,只读,不手改。
  • /www/server/nginx/conf/nginx.conf:面板升级时可能覆盖,但平时不动。可以在 http{} 块末尾加一行你的外挂目录。
  • /www/server/nginx/conf/zz-custom/*.conf:你的领地,面板永远不认识它。
include /www/server/nginx/conf/zz-custom/*.conf;

隐患在于:面板每次 reload nginx 之前不做语法预检的场景是存在的,你外挂目录里一个错别字,就能让整机 Nginx 起不来,全站 502。所以你要自己加一道闸:

#!/bin/bash
# /usr/local/bin/nginx-precheck.sh  由 root cron 每 5 分钟跑
set -e
if ! /www/server/nginx/sbin/nginx -t 2>/tmp/nginx-t.err; then
  logger -t nginx-precheck "CONFIG BROKEN: $(cat /tmp/nginx-t.err)"
  # 立刻从 git 回滚到上一个已知良好的提交
  cd /www/server/nginx/conf/zz-custom
  git checkout -- .
  git clean -fd
  /www/server/nginx/sbin/nginx -t && systemctl reload nginx
fi

zz-custom 做成一个 git 仓库,每次改完先本地 nginx -t 再 commit。这套组合下来,面板再怎么重写它自己的配置,你的自定义规则和网关逻辑都是版本化、可回滚的。

七、恢复面:面板元数据和业务数据必须分开备

面板的 sqlite 有 WAL,直接 cp 出来的 default.db 有概率打不开或者缺最近的事务。正确姿势是用 sqlite 自己的 backup API:

sqlite3 /www/server/panel/data/default.db ".backup '/backup/panel/default.db'"
sqlite3 /www/server/panel/data/default.db "PRAGMA integrity_check;"

面板侧需要备份的清单其实很短:

  • /www/server/panel/data/default.db(站点、数据库、FTP、计划任务元数据)
  • /www/server/panel/data/{port.pl,admin_path.pl}
  • /www/server/panel/vhost/(证书与站点配置)
  • /etc/wireguard/(隧道密钥,丢了就进不去管理面)

业务数据用 MySQL 物理备份或文件系统快照,跟面板元数据分开放。轻云互联后台的整机快照在这种场景下很好用,但要注意它是崩溃一致的——PHP 上传目录和 MySQL 数据目录能对上,那份 sqlite 还是要单独走 .backup,别指望快照能救回一个正在写入的 WAL 文件。

恢复顺序固定成三步,别乱:先恢复干净系统 + wg 隧道,能进管理面了再恢复面板元数据,最后恢复业务数据并逐个站点启 pool。顺序反了会出现站点配好了但证书和隧道都进不去、只能从控制台抢救的尴尬。

八、收尾:一次验收清单

# 1. 公网不该看到 8888
nmap -Pn -p8888 <公网IP>
# 2. 面板必须在 panel.slice 里
systemctl show -p ControlGroup bt-panel
cat /sys/fs/cgroup/panel.slice/{cpu.weight,memory.high,memory.max}
# 3. 每个站点的 FPM 进程 UID 必须不同
ps -eo user,cmd | grep 'php-fpm: pool'
# 4. 自定义 nginx 配置独立于面板
ls /www/server/nginx/conf/zz-custom/
# 5. sqlite 备份可读
sqlite3 /backup/panel/default.db "SELECT count(*) FROM sites;"

这套架构没有一行是在"加固宝塔"——宝塔作为 root 编排器的本质你没变,也变不了。你做的只是把它关进四个不同层级的笼子里:网络层看不到它、cgroup 层限死它、配置层绕开它、恢复层兜住它。真出事了,炸的是面板,不是你的业务。