本文记录我把一个约 300 个模组的 Forge 1.20.1 整合包(涟漪之篇·如涟漪之所见 0.2.23)开成公网服务器、和朋友联机的全过程,也记录了上线后踩过的坑和排查方法。
文中所有域名、IP、用户名都是示例值,照做时请换成你自己的。
0. 最终效果
- 朋友在启动器里填一个域名就能进服,实测端到端延迟 p50 约 39ms,没有丢包。
- 游戏跑在家里的 Ubuntu 主机上(Ryzen 9 9950X + 32GB 内存),存档留在自己手里。
- 有一个网页管理面板:开关服、看实时控制台、传文件,全程 HTTPS,并开启两步验证。
- 家里宽带没有公网 IP 也能用。
1. 整体架构:为什么用三台机器
家宽在 NAT 后面,外网连不进来,所以需要有公网 IP 的机器做入口。我把入口拆成了两台:
| 机器 | 角色 | 运行的服务 |
|---|---|---|
| 家里主机(Ubuntu 24) | 实际运行游戏 | Forge 服务端、MCSManager(面板 + 节点)、frpc |
| 入口机 A(国内云服务器,我用的阿里云) | 游戏入口 | frps,对外开放 TCP 25565 |
| 入口机 B(另一台 VPS) | 网页入口 | Nginx + HTTPS 证书,反向代理面板 |
家里主机和入口机 B 通过 ZeroTier 组成虚拟局域网,面板流量走 ZeroTier;游戏流量走 frp 隧道(为什么不走 ZeroTier,见第 10.1 节)。
玩家的 Minecraft 客户端
└─ mc.example.com:25565
└─ 入口机 A:frps 监听 25565
└─ frp 隧道(公网 TCP)
└─ 家里主机 frpc → 10.147.17.10:25565(Forge)
浏览器
├─ https://panel.example.com → 入口机 B Nginx → ZeroTier → 10.147.17.10:23333(面板 Web)
└─ wss://node.example.com → 入口机 B Nginx → ZeroTier → 10.147.17.10:24444(面板节点 Daemon)为什么要拆成两台入口:
- 游戏对延迟敏感,所以选离玩家近、延迟低的国内机器做游戏入口。
- 面板需要 HTTPS 域名,还要上传 GB 级的整合包。放到另一台已经有 Nginx 和证书的 VPS 上,就不会挤占游戏入口的带宽。另外,国内云服务器用域名在 80/443 端口建站,一般需要先做 ICP 备案。
- 只有一台云服务器也没问题:把 A、B 的工作都放在这一台上,原理完全一样。
为什么面板要用两个域名: MCSManager 分成「网页(Web)」和「节点(Daemon)」两个服务,浏览器会直接连接节点来上传文件、接收实时控制台。用 panel 和 node 两个域名,Nginx 就能在同一个 443 端口上区分后端,HTTPS 页面也能通过 WSS 连上节点,23333/24444 都不用对公网开放。
1.1 本文用到的示例值
| 名称 | 示例值 |
|---|---|
| 家里主机 ZeroTier IP | 10.147.17.10 |
| 入口机 B ZeroTier IP | 10.147.17.30 |
| 入口机 A 公网 IP | 203.0.113.10 |
| 入口机 B 公网 IP | 198.51.100.20 |
| 游戏域名 | mc.example.com |
| 面板域名 | panel.example.com |
| 节点域名 | node.example.com |
2. 准备工作
2.1 版本清单
| 项目 | 版本 |
|---|---|
| 整合包 | 涟漪之篇·如涟漪之所见 0.2.23 |
| 游戏 | Minecraft 1.20.1,Forge 47.4.16 |
| Java | 64 位 Java 17(Forge 1.20.1 用 17,不要套用新版 MC 的 Java 21 要求) |
| 面板 | MCSManager v10 |
| 内网穿透 | frp 0.52(TOML 配置格式) |
| 组网 | ZeroTier |
| 玩家启动器 | PCL(离线账号) |
2.2 配置 DNS
以 Cloudflare 为例,添加三条 A 记录:
| 类型 | 名称 | 内容 | 代理状态 |
|---|---|---|---|
| A | mc | 入口机 A 公网 IP | 仅 DNS(灰色云朵) |
| A | panel | 入口机 B 公网 IP | 仅 DNS(灰色云朵) |
| A | node | 入口机 B 公网 IP | 仅 DNS(灰色云朵) |
三条都必须关闭代理(灰云):Cloudflare 的普通代理只转发 HTTP(S),不转发 Minecraft 的 TCP 25565;免费计划的代理还限制单次上传 100MB,GB 级的整合包会被拒绝。
添加后在本机核对:
dig +short mc.example.com panel.example.com node.example.com应该输出你自己的公网 IP;如果出现 104.x、172.x 这类 Cloudflare 地址,说明代理没关。
2.3 ZeroTier 组网
家里主机和入口机 B 都加入同一个 ZeroTier 网络(安装与入网见 ZeroTier 官方文档)。在 ZeroTier Central 里给每台机器固定分配 IP,后面的监听地址和反向代理都写死了这些 IP,变了就会失效。
在每台机器上检查:
sudo zerotier-cli listnetworks
sudo zerotier-cli peers状态应为 OK。在入口机 B 上确认能连到家里主机:
ip route get 10.147.17.10
ping -c 3 10.147.17.10在 frp 方案下,入口机 A 不需要加入 ZeroTier;如果你打算用第 10.1 节提到的 Nginx stream 方案,A 才需要加入。
3. 把客户端整合包做成服务端
整合包是给客户端用的,不能直接拿来开服。它的 ZIP 里通常有一个 overrides/ 目录,里面才是 mods/、config/ 等内容。我的做法:
- 从
overrides/取出mods/、config/、defaultconfigs/、kubejs/、tacz/、resourcepacks/(以你的整合包实际内容为准)。 - 排除三类模组:作者在文件名里标了「客户端」的、
META-INF/mods.toml里声明clientSideOnly = true的,以及整合包里的可选模组。 - 剩下的模组不代表都能在专用服务端上跑。 我实测后还需要再排除 5 个没有标记的客户端模组,否则服务端启动时会崩溃:
| 模组 JAR | 崩溃原因 |
|---|---|
ruok-forge_1.20.1_Pre-Release_6-1.7.4.jar | 通用 mixin 引用了 ClientLevel,在 Mixin 应用阶段崩溃 |
MaFgLib-0.1.14-mc1.20.1.jar | 加载了客户端类 client/gui/screens/Screen |
oculus-mc1.20.1-1.8.0.jar | 同上(光影模组) |
Fastquit-3.0.1+1.20.1forge.jar | 同上 |
IMBlocker-5.5.4-forge+1.17-1.20.4.jar(输入法冲突修复) | 加载了客户端类 client/KeyMapping |
- 修改
config/QuantifiedAPI/quantified_config.json:把"enableGpuAcceleration"改为false,"preferredGpuBackend"改为"CPU_ONLY"。如果主机有 NVIDIA 显卡,这个模组会在服务端探测 OpenCL/Vulkan,每次停服都会在libEGL_nvidia.so里段错误退出(存档已保存,但看着很吓人)。 - 加入启动用的文件:
install-forge.sh、start-server.sh、user_jvm_args.txt、server.properties、eula.txt。
最终原包 297 个模组,服务端保留 264 个,打包后约 1.35GB。我把这些步骤写成了一个 Python 小脚本,排除模组时按 modId 而不是文件名匹配,这样整合包更新、JAR 文件名变了也能命中。客户端不需要删除这些模组,大家照常用原整合包进服即可。
install-forge.sh 下载 Forge 官方安装器、校验 SHA-1 后安装服务端:
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "$0")"
MC_JAVA="${MC_JAVA:-/usr/lib/jvm/java-17-openjdk-amd64/bin/java}"
if [[ -e libraries/net/minecraftforge/forge/1.20.1-47.4.16/unix_args.txt ]]; then
echo 'Forge is already installed; no change made.'
exit 0
fi
installer=forge-1.20.1-47.4.16-installer.jar
curl --fail --location --retry 3 --connect-timeout 20 \
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.16/$installer" \
--output "$installer"
printf '%s %s\n' 0d4bdf4f4d8558c789df7313aeaf02e2c8f9211d "$installer" | sha1sum --check -
"$MC_JAVA" -jar "$installer" --installServerstart-server.sh 固定使用 Java 17 启动(系统默认 Java 是 21 也不影响):
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "$0")"
MC_JAVA="${MC_JAVA:-/usr/lib/jvm/java-17-openjdk-amd64/bin/java}"
forge_args=libraries/net/minecraftforge/forge/1.20.1-47.4.16/unix_args.txt
if [[ ! -f "$forge_args" ]]; then
echo 'Forge is missing. First run: bash install-forge.sh' >&2
exit 1
fi
exec "$MC_JAVA" @user_jvm_args.txt "@$forge_args" nogui "$@"4. 家里主机:安装 Java 17 和 MCSManager
sudo apt update
sudo apt install -y openjdk-17-jre-headless curl wget vim python3 unzip
/usr/lib/jvm/java-17-openjdk-amd64/bin/java -version
curl -fL https://script.mcsmanager.com/setup_cn.sh -o /tmp/mcsm-setup-cn.sh
vim /tmp/mcsm-setup-cn.sh # 执行前先看一遍脚本
sudo bash /tmp/mcsm-setup-cn.sh --user mcsm
sudo systemctl enable --now mcsm-daemon mcsm-web--user mcsm不要省略,否则面板会默认以 root 运行。也可以从 MCSManager 的 GitHub Releases 下载官方包、校验 sha256 后手动安装。- 如果机器上装了 Docker,安装器可能会把
mcsm用户加进 docker 组。docker 组几乎等于 root 权限,不用 Docker 实例的话,建议移出:
id mcsm
sudo gpasswd -d mcsm docker
sudo systemctl restart mcsm-daemon mcsm-web4.1 让面板只监听 ZeroTier 地址
先停服务再改配置,避免运行中的服务把修改覆盖回去:
sudo systemctl stop mcsm-web mcsm-daemon
sudo cp -a /opt/mcsmanager/web/data/SystemConfig/config.json{,.bak}
sudo cp -a /opt/mcsmanager/daemon/data/Config/global.json{,.bak}
sudo vim /opt/mcsmanager/web/data/SystemConfig/config.json
sudo vim /opt/mcsmanager/daemon/data/Config/global.json只改对应字段,其他字段和节点密钥都保留:
// web/data/SystemConfig/config.json
"httpIp": "10.147.17.10",
"httpPort": 23333
// daemon/data/Config/global.json
"ip": "10.147.17.10",
"port": 24444(上面的 // 只是说明,JSON 文件里不能写注释。)改完后先校验 JSON,两个都通过再启动:
python3 -m json.tool /opt/mcsmanager/web/data/SystemConfig/config.json > /dev/null
python3 -m json.tool /opt/mcsmanager/daemon/data/Config/global.json > /dev/null
sudo systemctl start mcsm-daemon mcsm-web
sudo ss -lntp | grep -E '23333|24444'坑: 改完监听地址后,面板会显示「节点未连接」。这是因为默认节点地址还是localhost:24444,需要在面板的节点管理里改成10.147.17.10:24444。
4.2 防火墙:只允许入口机 B 访问面板端口
我的主机上还跑着别的服务,ufw 也没启用,贸然开启「默认拒绝入站」容易误伤,所以单独建了一条 iptables 链,只管这几个端口:
#!/bin/sh
# /usr/local/sbin/mc-firewall.sh
# 面板/节点端口只允许入口机 B 的 ZeroTier 地址访问;
# 游戏端口只允许本机访问(frpc 在本机连接,走回环接口,不受这条链限制)。
set -e
iptables -N MC_ZT 2>/dev/null || iptables -F MC_ZT
iptables -A MC_ZT -p tcp -s 10.147.17.30 -m multiport --dports 23333,24444 -j RETURN
iptables -A MC_ZT -p tcp -m multiport --dports 23333,24444,25565 -j DROP
iptables -C INPUT ! -i lo -j MC_ZT 2>/dev/null || iptables -I INPUT 1 ! -i lo -j MC_ZT如果你用第 10.1 节的 Nginx stream 方案(入口机 A 经 ZeroTier 连游戏端口),还要在 DROP 那行之前加一行,放行入口机 A 的 ZeroTier IP 访问 25565。
用 systemd 让它开机时在面板之前加载:
# /etc/systemd/system/mc-firewall.service
[Unit]
Description=Restrict MCSManager/Minecraft ports
Before=mcsm-web.service mcsm-daemon.service
After=network-pre.target
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/mc-firewall.sh
[Install]
WantedBy=multi-user.targetsudo chmod 755 /usr/local/sbin/mc-firewall.sh
sudo systemctl daemon-reload
sudo systemctl enable --now mc-firewall.service
sudo iptables -S MC_ZT4.3 开机时等 ZeroTier 地址就绪
面板监听的是 ZeroTier IP,如果开机时网卡还没拿到地址,面板会绑定失败。光写 After=zerotier-one.service 不够,因为 ZeroTier 服务启动了不代表 IP 已经分配好。我加了一个等待脚本:
#!/bin/sh
# /usr/local/sbin/wait-zt-ip.sh:最多等 120 秒
i=0
while [ "$i" -lt 120 ]; do
ip -4 addr show | grep -q 'inet 10.147.17.10/' && exit 0
i=$((i + 1))
sleep 1
done
echo "ZeroTier address not ready after 120s" >&2
exit 1再给 mcsm-web 和 mcsm-daemon 各加一个 drop-in 文件 /etc/systemd/system/mcsm-web.service.d/10-wait-zerotier.conf(daemon 同理):
[Unit]
After=zerotier-one.service mc-firewall.service
Wants=zerotier-one.service mc-firewall.service
[Service]
ExecStartPre=/usr/local/sbin/wait-zt-ip.sh
RestartSec=10sudo chmod 755 /usr/local/sbin/wait-zt-ip.sh
sudo systemctl daemon-reload4.4 先私下初始化管理员,再发布到公网
不要把还没设置管理员的安装页面暴露到公网。 在自己电脑上开一个 SSH 隧道连到家里主机:
ssh -N -L 127.0.0.1:12333:10.147.17.10:23333 -L 127.0.0.1:12444:10.147.17.10:24444 你的用户名@家里主机浏览器打开 http://127.0.0.1:12333,创建管理员并设置强密码,然后在账户设置里开启 TOTP 两步验证,退出后重新登录确认一遍。管理员账号不要共享给朋友;朋友需要帮忙管理时,单独建普通账号,只分配这一个实例。
5. 入口机 B:用 Nginx 反向代理面板(HTTPS + WSS)
5.1 申请证书
panel 和 node 两条 DNS 记录生效后,用 certbot 申请一张同时覆盖两个域名的证书:
sudo certbot certonly --nginx -d panel.example.com -d node.example.com证书在 /etc/letsencrypt/live/panel.example.com/,certbot 的定时任务会自动续期。
5.2 Nginx 配置
新建站点配置(Debian/Ubuntu 习惯放在 sites-available/ 再链接到 sites-enabled/):
map $http_upgrade $mcsm_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name panel.example.com;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 2g;
client_body_timeout 3600s;
location / {
proxy_pass http://10.147.17.10:23333;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $mcsm_connection_upgrade;
proxy_connect_timeout 10s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_request_buffering off;
proxy_buffering off;
}
}
server {
listen 443 ssl;
server_name node.example.com;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 2g;
client_body_timeout 3600s;
location / {
proxy_pass http://10.147.17.10:24444;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $mcsm_connection_upgrade;
proxy_connect_timeout 10s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_request_buffering off;
proxy_buffering off;
}
}
# 可选:HTTP 跳转 HTTPS。跳转写在 location 里,不影响证书续期验证
server {
listen 80;
server_name panel.example.com node.example.com;
location / {
return 301 https://$host$request_uri;
}
}几个关键点:
client_max_body_size 2g:上传超过这个大小会返回 413,按整合包大小调整。proxy_request_buffering off:不让 Nginx 先把大文件写到临时目录再转发。Upgrade/Connection头和长超时:实时控制台走 WebSocket,必须有。X-Real-IP用$remote_addr覆盖:客户端没法伪造自己的 IP(配合第 4.2 节的防火墙,只有入口机 B 能访问面板)。
检查通过再重载:
sudo nginx -t && sudo systemctl reload nginx入口机 B 的防火墙/安全组需要放行 TCP 443(以及可选的 80)、ZeroTier 的 UDP 9993。
5.3 在面板里配置节点地址映射
打开 https://panel.example.com 登录后:
- 节点地址保持
10.147.17.10:24444不改。 面板后端在家里主机本地直连节点,不绕经入口机 B;这样入口机 B 挂了,还能用 SSH 隧道管理。 - 在节点编辑页的「节点直连地址映射」里添加一条:
| 地址 | 端口 | 前缀 | |
|---|---|---|---|
| 来源(浏览器访问面板用的地址) | panel.example.com | 443 | 留空 |
| 目标(浏览器连接节点用的地址) | node.example.com | 443 | 留空 |
前端会按当前页面的 host:port 匹配来源,命中后改连目标;HTTPS 页面会自动使用 WSS。
- 再加一条应急映射
127.0.0.1:12333 → 127.0.0.1:12444,配合第 4.4 节的 SSH 隧道使用。 - 在「设置 → 安全设置」开启反向代理模式。不开的话,面板看到的所有登录都来自入口机 B 的 IP,「同 IP 登录失败次数限制」被任何人触发后,所有人都会被锁在外面。
坑: 在界面里编辑映射时,保存会整体覆盖映射列表。改完后检查两条映射是不是都还在。
最后实际测试一次上传和实时控制台。能打开登录页,不代表节点能用。
6. 入口机 A:用 frp 转发游戏端口
6.1 入口机 A 运行 frps
从 frp 的 GitHub Releases 下载对应版本(我用的 0.52.3),解压到 /opt/frp/。配置 frps.toml:
bindPort = 7000
auth.method = "token"
auth.token = "换成一串足够长的随机字符"
# 只允许 frpc 申请 25565,避免 token 泄露后被用来开任意端口
allowPorts = [{ single = 25565 }]systemd 服务:
# /etc/systemd/system/frps.service
[Unit]
Description=frp server
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/opt/frp/frps -c /opt/frp/frps.toml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now frps阿里云安全组放行:
| 协议/端口 | 用途 | 来源 |
|---|---|---|
| TCP 25565 | 玩家连接游戏 | 最好只放行你和朋友的公网 IP(理由见第 8 节) |
| TCP 7000 | 家里 frpc 连接 frps | 家宽 IP 会变,可以放开来源,靠 token 鉴权 |
| TCP SSH 端口 | 管理 | 仅自己的 IP |
6.2 家里主机运行 frpc
frpc.toml:
serverAddr = "203.0.113.10" # 入口机 A 公网 IP
serverPort = 7000
auth.method = "token"
auth.token = "和 frps 相同的 token"
[[proxies]]
name = "mc"
type = "tcp"
localIP = "10.147.17.10" # 与 server.properties 的 server-ip 一致
localPort = 25565
remotePort = 25565# /etc/systemd/system/frpc.service
[Unit]
Description=frp client
After=network-online.target
Wants=network-online.target
[Service]
User=你的普通用户名
ExecStart=/opt/frp/frpc -c /opt/frp/frpc.toml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target/opt/frp/frpc verify -c /opt/frp/frpc.toml
sudo systemctl daemon-reload
sudo systemctl enable --now frpc
journalctl -u frpc -n 30 --no-pager注意几点:
- frpc 重启时,所有玩家都会掉线一次,最好趁没人在线时改配置。
- 经过 frp 转发后,服务端日志里所有玩家的来源 IP 都是家里主机自己的地址。所以登录模组「按 IP 自动登录」、「单 IP 注册上限」、按 IP 封禁这类功能都会失效,要关掉。
- 我的 Forge 绑在 ZeroTier IP 上,是因为最早用的是 ZeroTier 转发方案。如果你只用 frp,可以把
server-ip和 frpc 的localIP都改成127.0.0.1,更简单。 - 游戏能承载多少人,取决于入口机 A 的公网带宽,见第 10.2 节。
7. 导入服务端并首次启动
7.1 上传
在面板里新建「本机节点上的自定义 Minecraft Java 实例」,选一个空的工作目录,通过文件管理上传服务端 ZIP 并解压。
1GB 以上的包走网页上传比较慢,也可以在自己电脑上用 scp 经 ZeroTier 直接传到家里主机(我实测 1~3 MB/s),再在面板里解压。
解压后,工作目录根部应该直接是:
mods/ config/ defaultconfigs/ kubejs/ tacz/ resourcepacks/
install-forge.sh start-server.sh user_jvm_args.txt
server.properties eula.txt不要多套一层文件夹。
7.2 安装 Forge,再改启动命令
- 实例启动命令先设为
bash install-forge.sh,点「启动」,等它装完。 - 把启动命令改成
bash start-server.sh并保存。(如果启动时又跑了一遍安装,就是这一步没保存。)
7.3 server.properties
只改下面这些字段,其他自动生成的字段保留:
server-ip=10.147.17.10
server-port=25565
level-name=world
online-mode=false # 离线账号(PCL 离线登录)需要
enforce-secure-profile=false
white-list=true
enforce-whitelist=true
hide-online-players=true # 状态查询只显示人数、不显示玩家名
allow-flight=true # 见第 10.4 节
enable-rcon=false
enable-query=false
view-distance=8
simulation-distance=6
max-players=8
max-tick-time=180000 # 大整合包保存/加载慢,放宽看门狗,避免被误判卡死而强制关服阅读并同意 Minecraft EULA 后,把 eula.txt 改成 eula=true。
7.4 内存
user_jvm_args.txt:
-Xms4G
-Xmx12G
-Dfile.encoding=UTF-832GB 的机器,开服后实际占用(RSS)约 6~9GB。不要把 -Xmx 设得太大,要给系统、面板和磁盘缓存留出余量。
7.5 首次启动
先用一个临时新世界测试,等控制台出现 Done (...)!。我去掉第 3 节那 5 个模组后,首次启动约 76 秒出现 Done (35.355s)!。
启动失败时:
- 看
logs/latest.log和crash-reports/,先处理第一处有效异常。 MixinApplyError或致命的... for invalid dist DEDICATED_SERVER:多半是还有客户端模组混在里面。把对应 JAR 移到disabled-mods/(不要直接删)。只有[mixin/]: Error loading class这种 WARN 一般无害。- 模组加载失败后,JVM 可能因为残留线程不退出:面板显示「运行中」,日志却不再更新。这时点「强制终止」,再去看
crash-reports/里的-fml.txt。 - 不要为了「能开起来」大量删除内容模组,那样会改变世界内容。
确认能稳定运行后,在实例的事件任务里开启「自动启动」和「自动重启」(我设了最多 3 次)。测试阶段不要开无限重启。
8. 离线账号与白名单(一定要看)
online-mode=false 允许离线账号进服,但服务器不会验证这个名字是不是你的。白名单只按名字筛选,知道或猜到白名单里玩家名的人,可以用同名离线账号冒名进服。
8.1 白名单操作
在面板控制台执行,一次一行、不加 /(游戏内才加 /):
whitelist add Steve
whitelist add Alex
whitelist list- 多条命令一次性粘贴进去,会被当成一条执行,日志里会出现
<--[HERE]。 - 玩家名要求 3~16 个字符,只能用英文字母、数字和下划线,区分大小写。名字超过 16 个字符,客户端会报
String too big (was N characters, max 16);中文名也会被拒绝。
8.2 核对离线 UUID
有时 whitelist add 会查到同名正版账号,写进 whitelist.json 的是正版 UUID,于是离线玩家进不来,提示「你不在此服务器的白名单中」。离线 UUID 的算法是对 OfflinePlayer:玩家名 做 MD5,再转成 v3 UUID,可以这样算:
import hashlib, uuid
def offline_uuid(name):
h = bytearray(hashlib.md5(f"OfflinePlayer:{name}".encode()).digest())
h[6] = h[6] & 0x0F | 0x30 # version 3
h[8] = h[8] & 0x3F | 0x80 # IETF variant
return str(uuid.UUID(bytes=bytes(h)))
print(offline_uuid("Steve"))如果 whitelist.json 里的 UUID 和算出来的不一样,改成离线 UUID 后在控制台执行 whitelist reload。
8.3 降低冒名风险
能做的缓解措施:
hide-online-players=true:别人查询服务器状态时看不到在线玩家的名字。- 不给任何人 OP,管理命令(比如
gamemode creative Steve)都在面板控制台执行。 - 不玩的时候在面板里关服。
这些只能降低风险。真正解决有两种办法:
- 只放行固定来源 IP:安全组里 25565 只允许你和朋友的公网 IP。
- 装登录验证模组:注意 Bukkit 的 AuthMe 插件不能用在 Forge 上。可以评估 Nedologin(有 Forge 1.20.1 版本),它服务端和所有玩家的客户端都要装。上线前先在测试副本上验证和整合包的兼容性,然后让自己人先登录、绑定凭据,别让陌生人抢先注册你的名字。
9. 备份与开机自启
9.1 备份
这个整合包自带 FTB Backups 2,默认每 30 分钟备份一次、有玩家在线才执行、保留 5 份,备份在实例的 backups/ 目录。要调整保留份数,改 config/ftbbackups2.json 里的 max_backups(模组会自动重载配置)。
- 配置写了不等于备份真的成功。第一份备份出来后,用
unzip -t检查一下,确认里面有level.dat、playerdata/、region/。 - 5 份每 30 分钟一次,最多只能回退约 2.5 小时的游戏进度;而且备份和存档在同一块硬盘上,硬盘坏了会一起丢。建议定期复制一份到别的机器。
- 千万不要把备份文件放进 MCSManager 的数据目录(
web/data/RemoteServiceConfig/、daemon/data/InstanceConfig/等)。面板会把目录里的每个文件当成配置加载,然后崩溃,报错Cannot read properties of null (reading 'ip')。
回档步骤:关服 → 把 world/ 改名留底 → 把 backups/ 里需要的 zip(里面有 world/)解压到实例根目录 → 开服。这些都能在面板的「文件管理」里完成。
9.2 开机自启链路
家里主机重启后,各服务的启动顺序是:ZeroTier → 防火墙规则 → 等待 ZeroTier IP → 面板 Web/Daemon → 实例自动启动 → frpc。重启后检查:
sudo zerotier-cli listnetworks
sudo systemctl status mcsm-daemon mcsm-web frpc --no-pager
sudo ss -lntp | grep -E '23333|24444|25565'注意,面板服务开机自启和游戏实例开机自启是两回事,后者要在面板的实例设置里单独开启。
10. 踩坑实录
10.1 几个人同时 Timed out:ZeroTier 丢包,改走 frp
最初的方案是在入口机 A 上用 Nginx stream 转发 TCP,经 ZeroTier 连到家里主机:
# 放在 nginx.conf 的主层级(http {} 和 events {} 外面),需要 stream 模块
stream {
server {
listen 25565;
proxy_connect_timeout 10s;
proxy_timeout 1h;
proxy_socket_keepalive on;
proxy_pass 10.147.17.10:25565; # 家里主机的 ZeroTier IP
}
}(stream {} 不能放进 conf.d/,那个目录是在 http {} 里加载的。也不要开 PROXY protocol,Forge 默认不认这个协议头。)
现象: 几个玩家在几秒内一起 lost connection: Timed out,重连时卡在登录界面。
排查: 所有玩家共用的只有「入口机 A ↔ 家里主机」这一段,于是我在家里主机上分别测量到入口机 A 的 ZeroTier 地址和公网地址的 TCP 建连耗时(各测 150 次):
python3 - <<'EOF'
import socket, time
def probe(host, port, n=150):
ok, fail = [], 0
for _ in range(n):
t = time.perf_counter()
try:
socket.create_connection((host, port), timeout=3).close()
ok.append((time.perf_counter() - t) * 1000)
except OSError:
fail += 1
time.sleep(0.1)
ok.sort()
if ok:
print(f"{host}: fail={fail} p50={ok[len(ok)//2]:.0f}ms p95={ok[int(len(ok)*0.95)]:.0f}ms max={ok[-1]:.0f}ms")
else:
print(f"{host}: all {fail} failed")
probe("入口机A的ZeroTier IP", 22) # 换成入口机 A 上一个对你开放的 TCP 端口
probe("203.0.113.10", 22) # 同一台机器的公网地址
EOFTCP 建连时丢一个包会触发约 1 秒的重传,所以丢包会表现为 p95/max 出现 1000ms 以上的尖峰。
结果: ZeroTier(UDP 隧道)丢包 4%~14%,延迟尖峰超过 1 秒;同一时间公网 TCP 0 丢包,稳定在 27~35ms。
处理: 游戏改走 frp(第 6 节),并在入口机 A 上注释掉 Nginx stream 的 include(两者不能同时占用 25565)。改完后端到端 p50 39ms、p95 48ms、0 丢包。
如果你的 ZeroTier 是直连、不丢包,Nginx stream 方案更简单;但上线前最好先用上面的脚本测一下。
10.2 所有人一起卡:入口机带宽只有 3 Mbps
现象: 三个人同时玩、特别是跑图时,所有人的延迟一起升高;但这时单独测新连接的延迟是正常的。
原因: 跑图时服务器要给玩家发 2~4 Mbit/s 的区块和实体数据,超过了入口机 A 约 3 Mbps 的出口带宽,数据都堵在入口机 A 上排队。家里主机到入口机 A 的隧道 RTT 只有 17ms、没有积压,瓶颈不在家里这一侧。
处理:
- 降低流量:
view-distance从 8 降到 6,entity-broadcast-range-percentage从 100 降到 75,估计能少四成左右的区块流量。 - 或者给入口机 A 提升带宽,比如阿里云改成「按使用流量计费」并调高峰值带宽。
经验: 选游戏入口机时,除了看延迟,还要看出口带宽。多人跑图的模组服,每人按 1 Mbps 以上估算比较稳妥。
10.3 一按 R 换弹就被踢:terra_entity 的「骑乘」键
这是排查最久的一个问题。
现象:
- 只有一个玩家(Mac 客户端)用枪按 R 换弹时被踢,每次都能复现;其他两人换弹完全正常。
客户端提示:
Internal Exception: java.lang.RuntimeException: Attempted to load class net/minecraft/client/player/LocalPlayer for invalid dist DEDICATED_SERVER服务端日志里,报错出现在 Netty 网络线程上,不是主线程:
[Netty Epoll Server IO #N/ERROR] [RuntimeDistCleaner/DISTXFORM]: Attempted to load class net/minecraft/client/player/LocalPlayer for invalid dist DEDICATED_SERVER- 服务器不崩,其他人不受影响。
排查过程:
| 步骤 | 做法 | 结论 |
|---|---|---|
| 1 | 看断开前的日志:大量 RS 网络统计弹药的诊断输出 | 时间上相关,但那段代码在主线程运行,报错却在网络线程,不是直接原因 |
| 2 | 静态扫描所有服务端模组里引用 LocalPlayer 的数据包类,一共 116 个 | 大多是正常的仅客户端代码,静态分析定位不出来 |
| 3 | 用 javap 检查枪械模组 TACZ 的相关消息类 | 都标了 @OnlyIn(Dist.CLIENT),排除 TACZ |
| 4 | 检查几个和换弹有关的附属模组及其 mixin | 写法规范,排除 |
| 5 | 分析报错形态:原始 RuntimeException,没有被编解码异常包装 | 说明发生在服务端处理客户端发来的模组数据包时 |
| 6 | 对比这个玩家的 mods/ 和整合包:297 个完全一致 | 不是模组差异,问题出在客户端发出的数据包本身 |
| 7 | 开启 JVM 类加载日志,复现一次,看报错前加载了哪些类 | 报错前 3ms,同一个网络线程上加载的唯一一个模组类是 terra_entity 的 CustomPacketPayload$IPayloadContext |
| 8 | 对比这个玩家的 options.txt 和整合包默认值 | 「泰拉生物·骑乘」被绑到了 R,和换弹同一个键;整合包默认是不绑定 |
第 7 步是破案的关键。在 user_jvm_args.txt 里临时加一行:
-Xlog:class+load=info:file=logs/classload.log:time,tid重启后复现一次被踢,然后在 logs/classload.log 里找报错时间点前后、和报错同一个线程 id(tid)加载的类,模组的类名会直接暴露是谁。查完记得把这一行删掉,否则日志会一直增长。
原因:
- 模组 bug: terra_entity 1.2.2 的网络上下文类里有一个
public Player player()方法,客户端分支返回LocalPlayer,但这个方法没有用@OnlyIn(Dist.CLIENT)隔离。JVM 校验这个类时,需要确认LocalPlayer是Player的子类,于是去加载LocalPlayer;专用服务端上的 Forge 禁止加载客户端类,直接抛异常。这个类只在服务器第一次收到 terra_entity 的客户端数据包时才会加载,所以开服、跑图都正常,一触发就踢人。 - 触发条件: 这个玩家改过很多按键,「骑乘」恰好绑在了 R 上。每次按 R 换弹,都会同时发出一个骑乘数据包。
处理:
- 在「选项 → 控制 → 按键绑定」里,把「骑乘」(泰拉生物)设为未绑定(选中后按 Esc)。也可以在游戏完全关闭时,把
options.txt里的key_key.terra_entity.ride:key.keyboard.r改成key.keyboard.unknown。 - 顺便检查一下 R 上有没有绑其他功能(比如光影重载、法术轮盘),一起恢复默认。
- 在模组作者修复之前,terra_entity 的骑乘功能在服务器上不可用:任何人用任何键触发都会被踢。建议提醒所有玩家不要绑定这个键。
通用方法: 凡是 Netty 线程报 ... for invalid dist DEDICATED_SERVER、而且只踢单个玩家的,都可以用「类加载日志 + 同线程追溯」来定位是哪个模组。
10.4 Flying is not enabled on this server
现象: was kicked for floating too long 或 Flying is not enabled on this server。
原因: allow-flight=false。模组的喷气背包、翅膀、法术浮空,以及网络抖动造成的位置不同步,都会触发原版的防飞行检测。
处理: 模组服把 allow-flight 设为 true。
10.5 常见问题速查
| 现象 | 原因 / 处理 |
|---|---|
| 面板 502 | 入口机 B 到家里主机 23333 不通:检查 ZeroTier、监听地址、防火墙 |
| 能登录面板,但控制台/上传失败 | 节点映射、node 域名证书、Nginx 的 Upgrade 头 |
| 上传返回 413 | Nginx 的 client_max_body_size 太小 |
| 改完监听地址后「节点未连接」 | 节点地址还是 localhost:24444,改成 ZeroTier IP |
面板 Web 一启动就退出,报 reading 'ip' | RemoteServiceConfig/ 里混进了多余文件,移出去 |
| 登录日志里的 IP 全是入口机 B | 「反向代理模式」没开或没保存 |
| 服务无法绑定 ZeroTier IP | 开机时 ZeroTier 地址还没分配,见第 4.3 节 |
| 启动后日志不动,面板显示运行中 | 模组加载失败但 JVM 没退出,强制终止后看 crash-reports/ |
停服时 SIGSEGV ... libEGL_nvidia.so | Quantified 探测了显卡,关掉 GPU 加速 |
| 启动时又跑了一遍 Forge 安装 | 启动命令没改成 bash start-server.sh,或者改了没保存 |
| 进服提示不在白名单 | 名字大小写不对,或者 whitelist.json 里是正版 UUID |
<--[HERE] | 多条控制台命令被一次粘贴进去了 |
| 进服提示模组不匹配 | 双方整合包版本、模组或配置不一致 |
11. 日常运维速查
| 要做的事 | 怎么做 |
|---|---|
| 开服/关服 | 面板实例页的「启动」/「关闭」(关闭会发送 stop,先保存世界再退出) |
| 加/删白名单 | 控制台:whitelist add 名字 / whitelist remove 名字 |
| 管理命令 | 在面板控制台执行,相当于最高权限,不用给 OP |
| 看日志 | 实例内 logs/latest.log;崩溃看 crash-reports/;面板自身看 journalctl -u mcsm-web -u mcsm-daemon |
| 看隧道 | journalctl -u frpc |
| 回档 | 见第 9.1 节 |
| 入口机 B 挂了 | 用第 4.4 节的 SSH 隧道,打开 http://127.0.0.1:12333 管理 |
| 换别的整合包 | 新建实例,同样用 25565;先关旧实例再开新实例,「自动启动」只开给当前在玩的实例 |
12. 进一步加固(建议)
- 离线服务器装登录验证模组,或者只放行固定 IP(第 8.3 节)。
- 定期把备份复制到另一台机器。
- 找个没人玩的时间,实际重启一次家里主机,验证整条开机链路能自动恢复。
- 在副本上做一次回档演练,确认备份真的能用。
注意本文精妙的地方在于巧妙的利用了各个服务器的优点,例如阿里云低的延迟,frp的稳定不丢包用于实时传输游戏服务,dimt域名自由和zerotier对于dimt有更大的带宽用于后续上传整合包,杜甫spark的计算能力强大。非常巧妙的利用了现有的环境搞定了大部分问题,内心十分快乐。美中不足的是阿里云的带宽自由5mb,只能勉强支持三人在线游玩,后面看来需要更强的200mb带宽的阿里云vps。