Files
squaddocker/scripts/mcsm.py
T
gongzhiyongandClaude Opus 5 fe256c619f feat: 新增 MCSManager 面板集成 —— 自定义市场模板与一键配置脚本
面板侧的多开方案,把首次部署踩到的坑全部固化,重建不必重新排查。

新增:
- mcsm-market.json  自定义市场,3 个端口预排好的 Squad 模板
- scripts/setup-panel.sh   幂等的面板配置脚本(装 Docker/登录私有仓库/
  开启 enableApiKey 与 allowUsePreset/指向自建市场)
- scripts/squad-instance.py  按实例号自动排布端口的实例管理脚本(已实测)
- scripts/mcsm.py  通用 API 封装
- PANEL.md  面板侧文档

修正官方 Squad 模板的三个致命问题(均已实测):
- image 指向 Docker Hub,在受限网络下必然 i/o timeout
- networkMode 为 bridge,Squad 依赖 Steam 查询协议,该模式下
  服务端能起但游戏内浏览器搜不到;且端口映射漏了查询口与 RCON 口
- startCommand 不带端口参数,多开时第二个实例会抢第一个的端口

记录两个文档未提及的面板开关:enableApiKey 与 allowUsePreset 默认均为
false,前者会让所有 apikey 请求返回 403;以及 session 鉴权除 cookie 外
还需把 token 作为 query 参数带上。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:40:09 +08:00

241 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
MCSManager API 封装 —— 用于编程化管理 Squad 服务器实例。
只用 Python 标准库,无第三方依赖(目标机不保证有 pip)。
凭据从环境变量读取,不接受命令行参数传入 —— 命令行参数会泄漏到进程列表:
export MCSM_URL='http://<面板地址>:23333'
export MCSM_APIKEY='<你的 apikey>'
用法:
./mcsm.py daemons 列出 daemon 节点(拿 daemonId)
./mcsm.py instances <daemonId> 列出该节点上的实例
./mcsm.py create-squad <daemonId> [选项] 创建 Squad 实例
./mcsm.py start <daemonId> <uuid>
./mcsm.py stop <daemonId> <uuid>
./mcsm.py restart <daemonId> <uuid>
./mcsm.py kill <daemonId> <uuid>
./mcsm.py status <daemonId> <uuid>
./mcsm.py command <daemonId> <uuid> "<控制台命令>"
./mcsm.py logs <daemonId> <uuid> [大小KB]
./mcsm.py delete <daemonId> <uuid> [--with-files]
注意:MCSManager 文档明确警告「提交所有参数,即使参数未定义,否则有概率触发
bug」。因此 create-squad 会提交完整请求体,而不是只提交要改的字段。
"""
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
# 实例状态码,来自 MCSManager 文档
STATUS = {
-1: "busy(忙 / 安装或更新中)",
0: "stopped(已停止)",
1: "stopping(正在停止)",
2: "starting(正在启动)",
3: "running(运行中)",
}
def _config():
url = os.environ.get("MCSM_URL", "").rstrip("/")
key = os.environ.get("MCSM_APIKEY", "")
if not url or not key:
sys.exit("错误: 需要设置环境变量 MCSM_URL 和 MCSM_APIKEY")
return url, key
def call(method, path, params=None, body=None):
"""调用 MCSManager API。apikey 走 query 参数,并带上文档要求的请求头。"""
base, key = _config()
q = dict(params or {})
q["apikey"] = key
target = f"{base}{path}?{urllib.parse.urlencode(q)}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(target, data=data, method=method)
# 这两个头是文档明确要求的,缺了会被拒
req.add_header("X-Requested-With", "XMLHttpRequest")
req.add_header("Content-Type", "application/json; charset=utf-8")
ctx = ssl._create_unverified_context() if base.startswith("https") else None
try:
with urllib.request.urlopen(req, timeout=30, context=ctx) as r:
return json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:500]
# 不要把 target 打出来 —— 它含 apikey
sys.exit(f"HTTP {e.code} {method} {path}\n{detail}")
except urllib.error.URLError as e:
sys.exit(f"连接失败 {method} {path}: {e.reason}")
def squad_instance_body(nickname, port, queryport, beaconport, rconport,
maxplayers, tickrate, image, data_dir, mem_mb,
server_name, rcon_password):
"""
构造 Squad 实例的完整配置。
要点:
- processType=docker + networkMode=host:Squad 依赖 Steam 查询协议,
端口映射模式会导致服务器在游戏内浏览器搜不到
- ie/oe/fileCode 用 utf8:文档示例给的 gbk 是 Windows 中文环境用的,
Linux 上会导致控制台日志乱码
- stopCommand 用 ^C:Squad 没有 stop 这类控制台命令,只能靠信号
- 不传 RANDOM:该变量在 cm2network/squad 镜像中无效(bash 内置特殊变量)
"""
return {
"nickname": nickname,
"startCommand": "bash entry.sh",
"stopCommand": "^C",
"cwd": "/home/steam",
"ie": "utf8",
"oe": "utf8",
"fileCode": "utf8",
"type": "universal",
"tag": [],
"endTime": 0,
"processType": "docker",
"updateCommand": "",
"actionCommandList": [],
"crlf": 1,
"enableRcon": True,
"rconPassword": rcon_password,
"rconPort": rconport,
"rconIp": "127.0.0.1",
"terminalOption": {"haveColor": False, "pty": True},
"eventTask": {"autoStart": False, "autoRestart": True, "ignore": False},
# pingConfig 默认端口是 Minecraft 的 25565,Squad 要指向 Steam 查询端口
"pingConfig": {"ip": "127.0.0.1", "port": queryport, "type": 1},
"docker": {
"containerName": nickname,
"image": image,
"memory": mem_mb,
"ports": [], # host 网络下端口映射无意义
"extraVolumes": [f"{data_dir}:/home/steam/squad-dedicated"],
"maxSpace": None,
"network": None,
"io": None,
"networkMode": "host", # Squad 的硬要求
"networkAliases": [],
"cpusetCpus": "",
"cpuUsage": 0,
"workingDir": "/home/steam",
"changeWorkdir": False,
"env": [
f"PORT={port}",
f"QUERYPORT={queryport}",
f"BEACONPORT={beaconport}",
f"RCONPORT={rconport}",
f"FIXEDMAXPLAYERS={maxplayers}",
f"FIXEDMAXTICKRATE={tickrate}",
f"SERVER_NAME={server_name}",
],
},
}
def show(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
cmd = sys.argv[1]
a = sys.argv[2:]
if cmd == "daemons":
r = call("GET", "/api/service/remote_services_list")
for d in r.get("data", []) or []:
print(f" daemonId={d.get('uuid')} {d.get('ip')}:{d.get('port')} "
f"available={d.get('available')} remarks={d.get('remarks','')}")
if not r.get("data"):
show(r)
elif cmd == "instances":
if not a:
sys.exit("用法: instances <daemonId>")
r = call("GET", "/api/service/remote_service_instances",
{"daemonId": a[0], "page": 1, "page_size": 50,
"instance_name": "", "status": ""})
for i in (r.get("data", {}) or {}).get("data", []) or []:
st = i.get("status")
print(f" uuid={i.get('instanceUuid')} "
f"{(i.get('config') or {}).get('nickname')} "
f"status={st} {STATUS.get(st, '?')}")
elif cmd == "create-squad":
if not a:
sys.exit("用法: create-squad <daemonId> [nickname] [port] ...")
daemon = a[0]
body = squad_instance_body(
nickname=a[1] if len(a) > 1 else "squad1",
port=int(a[2]) if len(a) > 2 else 7787,
queryport=int(a[3]) if len(a) > 3 else 27165,
beaconport=int(a[4]) if len(a) > 4 else 15000,
rconport=int(a[5]) if len(a) > 5 else 21114,
maxplayers=int(a[6]) if len(a) > 6 else 50,
tickrate=50,
image=os.environ.get("SQUAD_IMAGE",
"squaddocker.azurecr.cn/squad-server:v1"),
data_dir=os.environ.get("SQUAD_DATA_DIR", "/srv/squad/inst1"),
mem_mb=int(os.environ.get("SQUAD_MEM_MB", "6144")),
server_name=os.environ.get("SQUAD_SERVER_NAME", "Squad Server"),
# RCON 密码从环境变量读,不走命令行
rcon_password=os.environ.get("SQUAD_RCON_PASSWORD", ""),
)
if not body["rconPassword"]:
print("警告: 未设置 SQUAD_RCON_PASSWORD。RCON 密码为空时服务端不会"
"启动 RCON,21114 端口不会监听。", file=sys.stderr)
r = call("POST", "/api/instance", {"daemonId": daemon}, body)
show(r)
elif cmd in ("start", "stop", "restart", "kill"):
if len(a) < 2:
sys.exit(f"用法: {cmd} <daemonId> <uuid>")
r = call("GET", f"/api/protected_instance/{'open' if cmd == 'start' else cmd}",
{"uuid": a[1], "daemonId": a[0]})
show(r)
elif cmd == "status":
if len(a) < 2:
sys.exit("用法: status <daemonId> <uuid>")
r = call("GET", "/api/instance", {"uuid": a[1], "daemonId": a[0]})
st = (r.get("data") or {}).get("status")
print(f" status={st} {STATUS.get(st, '未知')}")
elif cmd == "command":
if len(a) < 3:
sys.exit('用法: command <daemonId> <uuid> "<命令>"')
r = call("GET", "/api/protected_instance/command",
{"uuid": a[1], "daemonId": a[0], "command": a[2]})
show(r)
elif cmd == "logs":
if len(a) < 2:
sys.exit("用法: logs <daemonId> <uuid> [大小KB]")
r = call("GET", "/api/protected_instance/outputlog",
{"uuid": a[1], "daemonId": a[0], "size": a[2] if len(a) > 2 else 512})
print(r.get("data") or r)
elif cmd == "delete":
if len(a) < 2:
sys.exit("用法: delete <daemonId> <uuid> [--with-files]")
r = call("DELETE", "/api/instance", {"daemonId": a[0]},
{"uuids": [a[1]], "deleteFile": "--with-files" in a})
show(r)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()