宝塔面板不该以 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 层限死它、配置层绕开它、恢复层兜住它。真出事了,炸的是面板,不是你的业务。