浏览器(public/,原生 JS SPA)
│ REST + SSE(Cookie 会话)
▼
server.js(入口,10 行)
└─ src/app.js(Express 装配:中间件 → 路由 → SSE → 静态页 → 错误兜底)
├─ src/auth.js 认证与用户(scrypt、持久化会话、限速、双角色)
├─ src/routes/
│ ├─ users.js /api/users 用户管理(admin)
│ ├─ host.js /api/host /api/servertypes /api/java 宿主机、服务端类型/版本、Java 运行时
│ ├─ tunnel.js /api/tunnel/components 穿透组件安装
│ └─ instances.js /api/instances/** 实例 CRUD 及全部子资源
├─ src/registry.js 实例注册表 + 服务端下载安装/重装流程(含 Forge/NeoForge 安装器)+ 指标轮询
├─ src/instance.js Instance 类(核心领域对象,见下)
├─ src/tasks.js 计划任务存储 + 30s 调度器
├─ src/backups.js tar.gz 备份/恢复
├─ src/archive.js 压缩包:zip 用 zlib 自读写,tar 家族调系统 tar(均先做安全校验)
├─ src/disk.js 磁盘用量:分区 statfs + 每实例体积后台缓存(60s)
├─ src/notify.js 告警推送:通用 Webhook / Discord / Telegram(去重 + 永不抛错)
├─ src/audit.js 操作审计:写操作通用中间件 + JSONL 落盘 + 滚动
├─ src/modrinth.js Modrinth 搜索/安装(loader 映射 + SHA-1 校验)
├─ src/rcon.js RCON 客户端(Source 协议,每命令短连接)
├─ src/totp.js 两步验证(RFC 6238,自实现,含恢复码)
├─ src/tunnels.js 隧道组件下载(ngrok/frpc/playit/bore)+ SSH 密钥
├─ src/servertypes.js 服务端类型注册表:10 种官方下载源(1h 版本缓存)
├─ src/java.js Java 运行时管理:Temurin 25/21/17/8 下载 + 按 MC 版本挑选
├─ src/mcping.js Minecraft status ping(连通性验证)
├─ src/bus.js SSE 事件总线
├─ src/utils.js 工具(下载、JSON 读写、ANSI 清洗、asyncHandler…)
└─ src/config.js 路径与常量(启动时创建 data/instances/backups/bin)
依赖方向自上而下,无循环:routes → (registry, tasks, backups, tunnels) → instance → (tunnels, mcping, bus, utils, config)。
一个 Instance = instances/<id>/ 下的真实服务端目录 + 最多两个子进程:
- 服务端进程:
java -Xmx… -jar server.jar nogui(新版 Forge/NeoForge 为java @libraries/…/unix_args.txt)- 额外 JVM 参数(
jvmArgs)接在默认值之后 —— 同名 flag HotSpot 取最后一个,所以用户能盖掉默认; 写了-Xms就不再发默认的-Xms512M。校验拒绝-Xmx/MaxHeapSize/MaxRAMPercentage(内存配额就是靠-Xmx落地的,放行等于让普通用户自己改配额)与-jar/-cp/@file(会改变到底启动了什么)。参数进的是spawn的 argv 数组、不过 shell,没有命令注入面 - stdout/stderr 按行解析:
Done (…s)!→ running;joined/left the game→ 玩家表 spawn本身失败(最常见:没装 Java,ENOENT)时 Node 不保证还会发'exit', 所以'error'回调里要在proc.pid为空时自己收尾 —— 否则实例永远卡在starting,既停不掉也起不来- stop = stdin 写
stop(优雅存档),30s 超时 SIGKILL;exit 事件统一复位状态 - 崩溃自动重启:退出码非 0 且不是 stop/kill/面板关停引起的,5s 后自动拉起。
10 分钟内超过 3 次就置
autoRestartBlocked停手 —— 端口占用、jar 损坏这类 "起来就死"的故障否则会无限重启,还会把真正的报错顶出日志缓冲区。 退出码 0 不重启:那是有人在控制台敲了stop,别跟他对着干。app.js的shutdown()必须先置panel.shuttingDown,否则面板正关着还在往回拉服 - 面板重启后恢复:
wasRunning随 start/stop/kill 持久化进注册表(崩溃退出时不翻转, 所以"崩了之后面板也挂了"仍会被拉回来);面板监听端口后resumeInstances()把autoStart && wasRunning的实例每 5s 拉起一个。用户主动停掉的实例wasRunning=false, 不会因为面板重启又自己跑起来 - 启动前查端口:同面板实例撞端口能报出是哪个实例;本机其它进程占用则读
/proc/net/tcp{,6}的 LISTEN 项判定(不用试 bind —— 那是异步的,而start()同步返回)。不查也能起,但 BindException 埋在 Java 栈里,还会触发崩溃自动重启反复撞 - CPU/RSS 每 2s 从
/proc/<pid>/stat|status采样,两档保留:秒级 150 点(≈5 分钟,实时曲线)+ 分钟级 1440 点(24 小时)。分钟档同时存均值和峰值 —— 只存均值会把瞬时尖峰抹平, 而排查 OOM 时要看的恰恰是尖峰。都在内存,面板重启即丢 - 口径:采到的是
VmRSS,而metrics.ramMax是-Xmx(堆)。两者不同口径,ram > ramMax是常态 —— 差额是 Metaspace / Code Cache / 线程栈 / GC 结构 / Netty direct buffer。仪表盘的内存条原先Math.min(100, …)一夹就永久钉在 100%, 等于把"健康的堆外开销"和"真要 OOM 了"渲染成同一个样子;现在超出部分换色并标注增量。 配额侧同理走utils.memFootprintMB()折算,见下面「内存配额的口径」
- 额外 JVM 参数(
- 隧道进程(独立于服务端,重启实例不断线):ngrok / frpc / playit / bore /
Pinggy / Serveo 六种驱动,统一输出解析(
\r/\n双分隔 + ANSI 清洗), 公网地址就绪后 4s 自动做一次 mcPing 连通性验证,失败原因写入tunnelError
状态经 snapshot() 序列化,通过 SSE state 事件推送;所有状态变更点都调用
emitState(),前端无需轮询。
只换服务端本体,世界、插件/模组、server.properties 原样保留 —— 和 installInstance
的关键区别就是不碰 server.properties:那里面是用户攒下来的全部配置,
按模板重写一遍等于清空。
type/version 在下载成功之后才写回,所以失败时实例仍是旧版本、照常能启动。
旧 jar 改了名才删(避免目录里堆历史版本)。代理换成服务端时补 eula.txt 与最小
server.properties,已存在则一概不动;按新类型的 dataDir 建 plugins/ 或 mods/。
默认在动手前跑一次完整备份 —— MC 不支持世界降级,换版本可能是不可逆的。
按服务端类别给一份已知配置文件清单,只列真实存在的(不存在就不列,免得点开一片空文件);
Fabric/Forge 的模组配置数量不定,额外扫一层 config/。
故意不做成表单:这些 YAML 的键随服务端版本一直在变,硬编码字段迟早对不上, 不如把人直接送到已有的文本编辑器前面。
复制实例目录,换 id / 名字 / 端口。要求源实例已停止 —— 边跑边拷世界会拿到一份 撕裂的存档,而那种损坏往往要等玩家进服才暴露。
端口必须换(findFreePort 跳过其它实例已配的端口和当前 LISTEN 的端口),否则
两个实例永远只能开一个。隧道配置不继承:里面有 token 和固定远程端口,
两个实例同时用会互相打架。logs/ crash-reports/ cache/ 不复制 —— 前两个是
上一个实例的历史,后者重新生成即可,整合包的 cache 可能有好几个 G。
Instance.canAccess(user) = 管理员 / 主人 / 协作者,是唯一的访问判定入口
(router.param('iid')、可见实例列表、/api/host 统计、SSE 首帧与 bus 广播过滤都走它)。
有意不走 canAccess 的地方:配额统计。实例数、内存、磁盘一律只算 owner ——
否则拉个小号互相加成协作者就能把各自的配额翻倍。
协作者能操作实例,但不能删实例、不能改协作者名单(isOwnerOrAdmin)。
删除用户时会把他从所有实例的名单里摘掉,否则残留一个已不存在的用户名。
utils.memFootprintMB(xmx) = xmx + max(memOverheadMinMB, xmx × memOverheadPct%),
是内存配额唯一的折算入口(quotaError、usageOf、/api/host 的 committedMem 都走它)。
为什么不能直接用 -Xmx 累加:-Xmx 只管堆,JVM 还要 Metaspace、Code Cache、线程栈、
GC 自身结构和 Netty 的 direct buffer —— MC 服务端网络层就是 Netty,这块不小。
实测 -Xmx4G 的 Paper 稳定运行 RSS 常在 4.5G 以上,重模组服更多。按 Σ-Xmx 把宿主机排满,
实际 RSS 之和必超,而超出的部分不会报错:是 OOM killer 半夜随机挑一个服务端杀掉。
沉默的超卖比当场拒绝难查得多。
余量取百分比与固定下限的较大值:小堆按比例算不够(1G 的 13% 只有 133M,盖不住
Metaspace + CodeCache + 线程栈),大堆按固定值算又不够(模组服 class 多、direct buffer 大)。
两个参数在 settings.thresholds 里,同时设 0 即退回纯 Σ-Xmx——
这是逃生阀,给"我自己知道我在超卖"的部署留的。
settings 用惰性 require:settings.js 自己 require 了 utils.js,顶层 require 会成环。
配额只在内存往上加时校验(PATCH 里 if (mb > inst.xmx))。持平和缩小一律放行 ——
前端保存实例设置时总会带上 xmx,不这么写的话,管理员一旦调低某人配额、或者口径变严,
已超额的用户会连改个实例名都 403,而"把内存调小自救"这条唯一出路恰好被同一条拦住。
分区容量走 fs.statfs(用 bavail 而非 bfree —— 后者含 root 保留块,普通用户拿不到)。
每实例体积 = 实例目录 + 它的备份目录,要递归 stat 几万个文件,不能在请求里现算:
后台每 60s 串行扫一遍缓存,接口与配额检查都读缓存。代价是最长有一个扫描周期的滞后,
对"别把磁盘占满"这个目的够用;删大文件/备份后可以 refresh(iid) 立即重算。
磁盘配额(limits.maxDiskMB,0 = 不限)在五处强制:上传、解压、打包、备份、建实例。
只靠定时扫描是挡不住的 —— 用户能在两次扫描之间连传十几个大文件,每次读到的都是同一个
"还没超"的旧数字。所以写入成功后立刻 bump() 把增量记回缓存;bump 遇到缓存里
还没有的实例要新建一条而不是直接返回,否则刚创建的实例在第一次扫描前是配额盲区。
上传把剩余额度直接压进流式上限,超额当场断流,不用等收完几个 GB 再拒;
解压把额度压进 extractArchive 的 maxBytes,在任何字节落盘前就拒。
五类事件:实例异常退出、重启风暴保护触发、备份失败、计划任务连续失败(≥3 次)、 宿主机磁盘 ≥90%。目标支持通用 Webhook(POST JSON)、Discord、Telegram,可分别开关。
三条硬约束:
- 永不影响主流程:
emit()同步返回,内部 fire-and-forget,失败只写面板日志。 备份已经失败了,不该再因为 webhook 超时炸第二次。 - 同事件同对象 5 分钟内只发一次(
dedupeKey),否则崩溃重启循环会把群刷爆。 - 目标地址发送前校验必须是
http(s),挡掉file://之类,别把面板变成内网探测器。
enable-rcon=true 且配了密码、实例在运行时,POST /command 走 RCON,否则走 stdin。
RCON 出错会回落 stdin 并在响应里带上 rconError,不让一次配置问题堵死操作。
为什么两条都要:stdin 是单向的,敲 list 只能等回显出现在日志流里再猜哪行是它;
RCON 直接返回这条命令的输出。服务端主线程卡死时 stdin 管道也会跟着堵,RCON 是独立通道。
每条命令开一个短连接 —— 对面板这种低频操作,省掉保活/重连/并发复用的状态机比省握手值。
选 Modrinth 是因为它有公开、免 key 的 v2 REST 接口,而且同一套接口同时覆盖 Bukkit 插件与 Fabric/Forge 模组 —— 面板两种实例都要用。(CurseForge 要申请 key, SpigotMC 没有官方下载 API。)
搜索按实例的 loader(paper→paper/spigot/bukkit,fabric→fabric,…)与 MC 版本过滤,
搜出来的都是装得上的。安装前三道闸:文件名必须是规矩的 .jar、下载地址必须是
cdn.modrinth.com、下完校验 SHA-1。先写临时文件、校验通过才 rename ——
这是从公网往用户服务器里放可执行 jar,校验失败绝不能留下半截文件在 plugins/ 等着被加载。
做成通用中间件而不是逐路由手写:后者一定会漏,新增路由时也没人记得补。
代价是动作名要从 method + path 反推,所以有一张对照表,匹配不到就退化成 METHOD /path。
记在 res.on('finish') 里,所以状态码一起记下来 —— 403/404 这类失败尝试同样留痕,
排查越权和暴力破解时那正是要看的。登录路由挂在 requireAuth 之前,用户名从 body 取,
失败的登录也有记录。GET 不记(否则日志全是刷新页面)。
password / token / secret 等字段递归替换成 *** —— 审计日志本身不该成为凭据泄露点。
上传接口 body 是文件原始字节,只记 JSON 请求的参数。
落 data/audit.log,超过 MCSP_AUDIT_MB(默认 16)滚一次,只留一个 .1。
| 文件 | 内容 | 写入时机 |
|---|---|---|
| users.json | 用户(scrypt 哈希) | 用户增删改 |
| sessions.json | 会话 token(7 天 TTL) | 登录/登出,防抖 500ms |
| instances.json | 实例元数据 + 穿透配置 | 实例/配置变更 |
| tasks.json | 计划任务 | 任务变更、每次触发 |
| settings.json | 注册开关、公告、备份保留策略、告警推送配置 | 系统设置保存时 |
| audit.log(.1) | 操作审计 JSONL(含失败尝试) | 每个写请求结束时 |
| frpc-<iid>.toml / playit-<iid>.toml | 隧道进程配置/密钥 | 隧道启动/绑定 |
| ssh/id_ed25519(.pub) | SSH 类隧道专用密钥 | 首次使用时生成 |
实例本体(世界、jar、插件)在 instances/,备份在 backups/,穿透二进制在 bin/。
单一 EventSource,事件带 iid 由前端过滤:
log(控制台行)· state(实例快照)· metrics(CPU/RAM 采样点)·
players · instances(列表变化)· tasks · components(组件安装进度)
- 全部
/api(除 health/auth/login)要求 HttpOnly Cookie 会话;admin 路由再叠requireAdmin - 文件 API 路径沙箱:
path.resolve后必须仍在实例目录内;在线编辑仅限文本扩展名 ≤2MB - 上传是原始流(
POST …/files/upload,body 即文件本身,不引 multipart 依赖): 先写.mcsp-upload-*再 rename,中断不留半截文件;文件名必须单段(禁/\...与控制字符), 超过MCSP_MAX_UPLOAD_MB(默认 2048)立即断流回 413 - 下载走同一个沙箱:文件用
res.download原样回传,目录现tar czf -流式打包(不落盘、 客户端断开即 SIGKILL 掉 tar);实例根目录不给下载,那是「备份」的活(会先 save-all) - 备份 id 必须匹配
^[\w.-]+\.tar\.gz$—— Express 会解码:id,不校验的话..%2F能带着path.join走出backups/(download/restore/delete 三处共用该校验) - 解压(src/archive.js)把压缩包当不可信输入,落盘前整包体检:条目名含
..拒绝、 绝对路径夹回 dest 内(落地路径再校验一次)、符号链接条目一律拒绝、加密包拒绝、 解压后总体积超MCSP_MAX_EXTRACT_MB(默认 8192)拒绝。 软链这条不能省:GNU tar 自己会挡..,却挡不住「先解出esc -> /,下次再往esc/里写」 这种跨两次操作的逃逸 —— 多租户下那就是越过了实例隔离。 tar 家族先tar -tvf列一遍清单做校验再-xf(多一趟解压,换确定性); zip 是面板用 zlib 自己读写的,不依赖unzip,顺带认 zip64 和没有 UTF-8 标志位的 GBK 文件名。 同一实例的打包/解压串行(archiveBusy),打包先写.mcsp-archive-*再 rename - 备份保留策略(
backupKeepCount份 /backupKeepDays天,各自 0 为不限)在每次备份成功后 执行一次,不另开定时器 —— 备份是唯一让backups/<iid>/变大的动作。两个条件取并集; 最新的一份永远保留,否则天数配得比备份间隔还短时会把刚做完的那份也删掉。 tar 失败留下的半截包会立即清掉,不然它会一直占着保留份数 - 登录限速(5 次失败锁 1 分钟)落盘到
data/login-attempts.json—— 只放内存的话, 面板一重启计数就清零,想爆破的人只要能触发一次重启就重新有 5 次机会 - API Token:
Authorization: Bearer <token>,存 sha256 摘要,明文只在创建时返回一次 (它会被写进别人的 crontab,泄露面比会话 cookie 大)。不允许用 Token 再创建 Token —— 否则一次泄露能自我延续 - 会话管理:用户能看到自己的活跃会话(只回 token 前 12 位 + IP/UA/最后活跃)并逐个踢掉, 或一键「退出其它设备」;每小时清一次过期会话
- 两步验证(TOTP,src/totp.js):自实现 RFC 6238(算法二十来行,不值得为它在认证路径上
引一棵依赖树),对过官方测试向量。前后各容一个 30s 窗口(手机时间差几十秒是常态);
比较用
timingSafeEqual。启用时发 8 个一次性恢复码 —— 没有它用户会被自己永久锁在外面。 验证码错误同样计入登录限速,否则第二道门可以无限暴力。 有意不生成二维码:唯一省事的做法是把密钥拼进第三方二维码服务的 URL, 那等于把用户的 2FA 种子发给别人 - 隧道配置输入全部白名单化清洗
- 全局错误中间件 +
asyncHandler:异步路由抛错返回 500 JSON,不打崩进程
PM2 fork 模式(见 ecosystem.config.js)。面板是有状态进程(会话、SSE 订阅、
子进程句柄),不能用 cluster 多副本。SIGTERM/SIGINT 时向所有子服发送
stop 落盘,8s 后退出。
npm test(scripts/smoke.js)先在临时目录跑一遍压缩模块往返(打包 → 解压 → 逐字节比对 →
体积上限),再对运行中的面板做真实 API 回归:健康检查、鉴权边界(401/403/404)、
路径沙箱(上传/下载/解压/打包/重命名)、实例全部子资源读取。