diff --git a/client/src/pages/GameDeploymentPage.tsx b/client/src/pages/GameDeploymentPage.tsx
index a27a383e..05105b69 100644
--- a/client/src/pages/GameDeploymentPage.tsx
+++ b/client/src/pages/GameDeploymentPage.tsx
@@ -41,6 +41,7 @@ interface GameInfo {
url: string
docs?: string
system?: string[]
+ login_anonymous?: boolean
supportedOnCurrentPlatform?: boolean
currentPlatform?: string
panelCompatibleOnCurrentPlatform?: boolean
@@ -59,6 +60,14 @@ interface Games {
[key: string]: GameInfo
}
+const quoteSteamCMDArgument = (value: string, platform?: string): string => {
+ if (platform === 'Windows') {
+ return `'${value.replace(/'/g, "''")}'`
+ }
+
+ return `'${value.replace(/'/g, "'\\''")}'`
+}
+
// 辅助函数:判断是否为 Windows 平台
const isWindowsPlatform = (systemInfo: any): boolean => {
// 优先使用 rawPlatform(原始平台标识)
@@ -2543,17 +2552,24 @@ const GameDeploymentPage: React.FC = () => {
useEffect(() => {
if (showInstallModal && selectedGame) {
const forceInstallDir = `force_install_dir "${installPath.trim()}"`
+ const steamLoginArgs = [
+ 'login',
+ quoteSteamCMDArgument(steamUsername.trim(), selectedGame.info.currentPlatform),
+ ...(steamPassword.trim()
+ ? [quoteSteamCMDArgument(steamPassword.trim(), selectedGame.info.currentPlatform)]
+ : [])
+ ]
const loginCommand = useAnonymous
? 'login anonymous'
- : `login ${steamUsername.trim()} ${steamPassword.trim()}`
+ : steamLoginArgs.join(' ')
const appUpdateCommand = validateGameIntegrity
? `app_update ${selectedGame.info.appid} validate`
: `app_update ${selectedGame.info.appid}`
// force_install_dir 必须在 login 之前,否则 SteamCMD 会报错
- const fullCommand = `steamcmd +${forceInstallDir} +${loginCommand} +${appUpdateCommand} +quit`
+ const fullCommand = `+${forceInstallDir} +${loginCommand} +${appUpdateCommand} +quit`
setSteamcmdCommand(fullCommand)
}
}, [showInstallModal, selectedGame, useAnonymous, steamUsername, steamPassword, validateGameIntegrity, installPath])
@@ -2628,6 +2644,12 @@ const GameDeploymentPage: React.FC = () => {
// 打开安装对话框的通用函数
const openInstallModal = async (gameKey: string, gameInfo: GameInfo) => {
const defaultInstanceName = gameInfo.game_nameCN
+ const shouldUseAnonymous = gameInfo.login_anonymous !== false
+ setUseAnonymous(shouldUseAnonymous)
+ if (shouldUseAnonymous) {
+ setSteamUsername('')
+ setSteamPassword('')
+ }
// 检查是否存在同名实例
try {
@@ -2909,11 +2931,11 @@ const GameDeploymentPage: React.FC = () => {
return
}
- if (!useAnonymous && (!steamUsername.trim() || !steamPassword.trim())) {
+ if (!useAnonymous && !steamUsername.trim()) {
addNotification({
type: 'error',
title: '参数错误',
- message: '请填写Steam账户信息'
+ message: '请填写Steam用户名'
})
return
}
@@ -2946,7 +2968,7 @@ const GameDeploymentPage: React.FC = () => {
instanceName: instanceName.trim(),
useAnonymous,
steamUsername: useAnonymous ? undefined : steamUsername.trim(),
- steamPassword: useAnonymous ? undefined : steamPassword.trim(),
+ steamPassword: useAnonymous || !steamPassword.trim() ? undefined : steamPassword.trim(),
steamcmdCommand: steamcmdCommand.trim(),
existingInstanceId: currentExistingInstanceId || undefined,
updateInstanceInfo: currentUpdateInstanceInfo,
@@ -5763,15 +5785,18 @@ const GameDeploymentPage: React.FC = () => {
)}
@@ -5844,18 +5869,18 @@ const GameDeploymentPage: React.FC = () => {
@@ -5875,7 +5900,7 @@ const GameDeploymentPage: React.FC = () => {
disabled={
!installPath.trim() ||
!instanceName.trim() ||
- (!useAnonymous && (!steamUsername.trim() || !steamPassword.trim()))
+ (!useAnonymous && !steamUsername.trim())
}
className="px-4 py-2 disabled:bg-gray-400 text-white rounded-lg transition-colors flex items-center space-x-2 bg-blue-600 hover:bg-blue-700"
>
diff --git "a/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md" "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md"
new file mode 100644
index 00000000..9c5ef3b2
--- /dev/null
+++ "b/docs/\345\206\205\347\275\221\347\251\277\351\200\217\346\217\222\344\273\266\351\234\200\346\261\202\350\257\264\346\230\216.md"
@@ -0,0 +1,95 @@
+# 内网穿透插件需求说明
+
+## 背景
+
+家用主机、NAS、宿舍网络、运营商 CGNAT 等没有公网入口的环境中,游戏服务端即使启动成功,也不方便让外部玩家连接。
+
+这个问题不是泰拉瑞亚特有的。Minecraft、Palworld、Valheim、Project Zomboid、Factorio 等游戏只要运行在不可直连的网络里,都会遇到类似的端口暴露问题。因此内网穿透能力应当作为跨游戏的可选工具实现,而不是绑定到某一个游戏部署流程。
+
+## 目标
+
+提供一个可选工具,让没有公网 IP 的设备也能把游戏服务端端口暴露给外部玩家。插件只负责网络入口,不改变游戏实例本身的部署、配置和启动逻辑。
+
+## 支持对象
+
+第一阶段建议支持:
+
+- frp 客户端模式:适合已有 frps 服务器的用户。
+- EasyTier 组网模式:适合希望多节点虚拟局域网互通的用户。
+
+后续可以扩展到其他工具,但需要满足同一套安全边界:配置可审计、启动命令受控、敏感信息脱敏、进程可停止。
+
+## MVP 能力
+
+- 从实例列表选择一个游戏实例,读取实例名称、工作目录和端口信息。
+- 支持手动填写本地地址、本地端口、远端端口、远端服务器、认证 token 或组网密钥。
+- 生成 frpc 或 EasyTier 配置文件,默认保存到 `server/data/tunnels/` 下。
+- 通过实例管理创建一个独立的“穿透实例”,让用户用现有实例启停能力管理隧道进程。
+- 显示连接说明,例如 `远端地址:远端端口` 或 EasyTier 虚拟 IP。
+- 不要求游戏实例和穿透实例同生命周期绑定,避免穿透失败影响游戏服务本身。
+
+## 非目标
+
+- 不把 frp/EasyTier 变成游戏部署前置依赖。
+- 不替用户申请公网服务器或托管中继。
+- 不绕过游戏本身的账号、白名单、RCON、反作弊或服务端安全配置。
+- 不默认启用 UPnP 或自动开放所有端口。
+- 不在仓库内提交 frp、EasyTier 等第三方二进制文件。
+
+## 推荐实现方式
+
+### 当前可复用能力
+
+项目已有插件体系和示例插件,可以直接复用:
+
+- 插件目录:`server/data/plugins/`。
+- 插件配置:`plugin.json`,支持 `enabled`、`hasWebInterface`、`entryPoint`、`category` 等字段。
+- 插件 API 客户端:`gsm3-api.js`,请求前缀为 `/api/plugin-api`。
+- 实例 API:`getInstances()`、`createInstance()`、`startInstance()`、`stopInstance()`、`restartInstance()`、`deleteInstance()`。
+- 文件 API:`mkdir()`、`writeFile()`、`readFile()`、`exists()`、`listFiles()` 等,可用于写入 `server/data/tunnels/` 下的配置文件。
+- 终端 API:可查看终端会话和活跃进程,MVP 阶段只作为状态辅助,不直接拼接任意命令。
+
+### 插件优先
+
+先做成 `server/data/plugins/tunnel-helper` 这类可选插件:
+
+- `plugin.json` 默认 `enabled: false`,避免默认安装后自动出现网络暴露入口。
+- `index.html` 只做配置生成、实例创建和状态查看。
+- 复用 `gsm3-api.js` 的实例 API、文件 API 和终端/进程列表 API。
+
+这种方式与当前插件体系匹配,风险较低,也不会把第三方网络工具耦合进核心部署页。
+
+### 核心 API 补强
+
+如果后续要做成完整的一键体验,核心需要补一个受限的后台进程/工具管理能力:
+
+- 管理受信任工具定义:工具名称、版本、下载地址、校验和、支持平台。
+- 统一保存配置:配置文件、敏感字段、脱敏展示、备份恢复。
+- 统一管理进程:启动、停止、重启、日志、健康状态、退出码。
+- 限制命令注入:不要让插件拼接任意 shell 字符串后直接执行。
+- 与实例端口关联:从游戏配置或实例元数据中读取端口,减少重复输入。
+
+## 安全与维护约束
+
+- 所有后端接口必须加认证中间件。
+- 持久化数据放在 `server/data` 下,兼容开发和打包路径。
+- token、密钥、服务端地址等敏感字段不要出现在日志和普通列表展示中。
+- 下载第三方工具必须记录来源、版本、哈希校验和失败回滚策略。
+- Windows、Linux、Docker 三种运行方式的路径、权限和停止信号要分别验证。
+- 默认生成的配置应当最小权限暴露,只映射用户明确选择的端口。
+
+## 下一步实现计划
+
+1. 创建 `server/data/plugins/tunnel-helper/`,复制示例插件的 `gsm3-api.js`,新增默认关闭的 `plugin.json`。
+2. 实现单页表单:选择已有游戏实例,选择 `frp` 或 `EasyTier`,填写本地端口、远端地址、远端端口和认证字段。
+3. 用文件 API 生成配置:frp 写入 `tunnels//frpc.toml`,EasyTier 写入 `tunnels//easytier.json` 或启动参数说明。
+4. 用实例 API 创建穿透实例:`workingDirectory` 指向隧道配置目录,`startCommand` 引用用户已安装的 `frpc` 或 `easytier-core` 可执行文件,`stopCommand` 使用 `ctrl+c`。
+5. 增加连接结果展示:保存配置路径、实例名称、远端连接地址、关联的游戏实例和端口。
+6. 增加敏感字段处理:表单默认隐藏 token,列表展示脱敏;后续如需服务端日志脱敏,再补核心 API。
+7. 再评估一键下载:确认工具版本、平台包、哈希校验和更新策略后,再考虑下载器和工具管理 API。
+
+## PR 拆分建议
+
+1. 通用内网穿透插件需求说明和界面草图。
+2. `tunnel-helper` 插件 MVP:生成配置并创建受管实例。
+3. 核心进程/工具 API 补强,完善二进制下载、校验、日志和状态检测。
diff --git "a/docs/\346\263\260\346\213\211\347\221\236\344\272\232\351\203\250\347\275\262\351\234\200\346\261\202\350\257\264\346\230\216.md" "b/docs/\346\263\260\346\213\211\347\221\236\344\272\232\351\203\250\347\275\262\351\234\200\346\261\202\350\257\264\346\230\216.md"
new file mode 100644
index 00000000..c96f907d
--- /dev/null
+++ "b/docs/\346\263\260\346\213\211\347\221\236\344\272\232\351\203\250\347\275\262\351\234\200\346\261\202\350\257\264\346\230\216.md"
@@ -0,0 +1,65 @@
+# 泰拉瑞亚部署需求说明
+
+## 背景
+
+Steam 游戏部署列表中缺少原版泰拉瑞亚,用户需要回到文档手动下载、上传、解压和交互式配置。
+
+本需求只覆盖泰拉瑞亚本身的部署入口、启动命令和 `serverconfig.txt` 可视化配置。没有公网入口、端口暴露、CGNAT 等问题统一放到通用的内网穿透插件需求中处理。
+
+## 已落地的最小改进
+
+### Steam 部署入口
+
+在 `server/data/games/installgame.json` 中新增 `Terraria`:
+
+- 使用 SteamCMD 专用服务器列表中的 Terraria AppID:`105600`。
+- 默认端口:`7777/TCP`。
+- 默认不使用匿名登录,安装时需要填写已拥有 Terraria 的 Steam 用户名。密码可留空,让 SteamCMD 在终端中交互式输入密码和 Steam Guard 码。
+- 清单内提供 Windows/Linux 默认启动命令,避免实例创建后回落为 `none`。
+- 关联现有泰拉瑞亚开服文档。
+- 在提示中建议用 `serverconfig.txt` 启动,避免首次启动进入交互式建世界流程。
+
+推荐 Linux 实例启动命令:
+
+```bash
+./TerrariaServer.bin.x86_64 -config serverconfig.txt
+```
+
+Windows 可改为:
+
+```powershell
+.\TerrariaServer.exe -config serverconfig.txt
+```
+
+### 可视化配置模板
+
+在 `server/data/gameconfig/泰拉瑞亚.yml` 中新增 `serverconfig.txt` 模板:
+
+- 世界路径、自动建图、种子、世界名、难度。
+- 最大玩家数、端口、密码、MOTD。
+- 世界目录、封禁列表、作弊防护、语言、UPnP、NPC 同步、优先级。
+- 旅途模式权限项。
+
+模板使用现有 `properties` 解析器。Terraria 的 `0/1` 开关用 `select` 表达,避免保存成 `true/false` 后与服务端配置格式不一致。
+
+## 使用提示
+
+### 安装命令
+
+SteamCMD 登录参数经过终端 shell 执行。包含 `$`、空格、`&`、引号等字符的密码不能直接裸写在命令行中,否则可能被 shell 解释。
+
+推荐在密码输入框留空,生成如下命令后在终端交互式输入密码和 Steam Guard 码:
+
+```powershell
+.\steamcmd.exe +force_install_dir "D:\Games\Terraria" +login xiwangly2 +app_update 105600 validate +quit
+```
+
+### 外部连接
+
+Terraria 默认监听 `7777/TCP`。外部玩家能否连接取决于防火墙、路由器端口转发、公网 IP 或内网穿透能力。跨游戏的端口暴露能力不放在泰拉瑞亚部署需求中实现,详见 `docs/内网穿透插件需求说明.md`。
+
+## PR 拆分建议
+
+1. 泰拉瑞亚 Steam 安装清单。
+2. 泰拉瑞亚 `serverconfig.txt` 可视化配置模板。
+3. SteamCMD 登录参数转义、密码可选输入、日志脱敏。
diff --git "a/server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml" "b/server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml"
new file mode 100644
index 00000000..8ca9ac80
--- /dev/null
+++ "b/server/data/gameconfig/\346\263\260\346\213\211\347\221\236\344\272\232.yml"
@@ -0,0 +1,346 @@
+meta:
+ game_name: "泰拉瑞亚"
+ config_file: "serverconfig.txt"
+ parser: "properties"
+
+sections:
+ - key: "serverconfig.txt"
+ fields:
+ - name: "world"
+ display: "世界文件路径"
+ default: "Worlds/world1.wld"
+ type: "string"
+ description: "要加载的世界文件。使用相对路径时会相对于服务端工作目录解析,建议与服务端文件放在同一持久化目录中"
+ - name: "autocreate"
+ display: "自动创建世界大小"
+ default: "1"
+ type: "select"
+ description: "当 world 指定的世界不存在时自动创建世界"
+ options:
+ - value: "0"
+ label: "不自动创建"
+ - value: "1"
+ label: "小世界"
+ - value: "2"
+ label: "中世界"
+ - value: "3"
+ label: "大世界"
+ - name: "seed"
+ display: "世界种子"
+ default: ""
+ type: "string"
+ description: "自动创建世界时使用的种子,留空则随机生成"
+ - name: "worldname"
+ display: "世界名称"
+ default: "Terraria"
+ type: "string"
+ description: "自动创建世界时使用的世界名称"
+ - name: "difficulty"
+ display: "世界难度"
+ default: "0"
+ type: "select"
+ description: "自动创建世界时使用的难度"
+ options:
+ - value: "0"
+ label: "经典"
+ - value: "1"
+ label: "专家"
+ - value: "2"
+ label: "大师"
+ - value: "3"
+ label: "旅途"
+ - name: "maxplayers"
+ display: "最大玩家数"
+ default: 8
+ type: "number"
+ description: "允许加入服务器的最大玩家数量,官方配置示例范围为 1 到 255"
+ - name: "port"
+ display: "服务器端口"
+ default: 7777
+ type: "number"
+ description: "Terraria 默认使用 7777 TCP"
+ - name: "password"
+ display: "服务器密码"
+ default: ""
+ type: "string"
+ description: "加入服务器需要输入的密码,留空表示无密码"
+ - name: "motd"
+ display: "每日消息"
+ default: ""
+ type: "string"
+ description: "玩家进入服务器时显示的消息"
+ - name: "worldpath"
+ display: "世界目录"
+ default: "Worlds"
+ type: "string"
+ description: "世界文件保存目录。Terraria 配置文件中不支持 $HOME 或 ~ 展开,建议使用相对路径或绝对路径"
+ - name: "worldrollbackstokeep"
+ display: "世界回滚备份数量"
+ default: 2
+ type: "number"
+ description: "保留的滚动世界备份数量"
+ - name: "banlist"
+ display: "封禁列表路径"
+ default: "banlist.txt"
+ type: "string"
+ description: "封禁列表文件位置,默认在服务端工作目录下"
+ - name: "secure"
+ display: "作弊防护"
+ default: "1"
+ type: "select"
+ description: "启用 Terraria 服务端的额外作弊防护"
+ options:
+ - value: "1"
+ label: "启用"
+ - value: "0"
+ label: "关闭"
+ - name: "language"
+ display: "服务器语言"
+ default: "zh-Hans"
+ type: "select"
+ description: "服务端输出语言"
+ options:
+ - value: "zh-Hans"
+ label: "简体中文"
+ - value: "en-US"
+ label: "English"
+ - value: "de-DE"
+ label: "Deutsch"
+ - value: "it-IT"
+ label: "Italiano"
+ - value: "fr-FR"
+ label: "Francais"
+ - value: "es-ES"
+ label: "Espanol"
+ - value: "ru-RU"
+ label: "Русский"
+ - value: "pt-BR"
+ label: "Portugues"
+ - value: "pl-PL"
+ label: "Polski"
+ - name: "upnp"
+ display: "UPnP 自动端口转发"
+ default: "0"
+ type: "select"
+ description: "是否尝试通过 UPnP 自动转发端口。没有公网 IP 或处于 CGNAT 时仍需使用内网穿透等方案"
+ options:
+ - value: "0"
+ label: "关闭"
+ - value: "1"
+ label: "启用"
+ - name: "npcstream"
+ display: "NPC 同步频率"
+ default: 60
+ type: "number"
+ description: "降低敌怪跳跃感但会增加带宽占用,0 表示关闭"
+ - name: "priority"
+ display: "进程优先级"
+ default: "1"
+ type: "select"
+ description: "服务端进程优先级"
+ options:
+ - value: "0"
+ label: "实时"
+ - value: "1"
+ label: "高"
+ - value: "2"
+ label: "高于正常"
+ - value: "3"
+ label: "正常"
+ - value: "4"
+ label: "低于正常"
+ - value: "5"
+ label: "空闲"
+ - name: "slowliquids"
+ display: "降低液体更新量"
+ default: "0"
+ type: "select"
+ description: "可降低同一时间移动的液体上限,可能减少卡顿但液体结算更慢"
+ options:
+ - value: "0"
+ label: "关闭"
+ - value: "1"
+ label: "启用"
+
+ - key: "journey"
+ fields:
+ - name: "journeypermission_time_setfrozen"
+ display: "旅途权限:冻结时间"
+ default: "2"
+ type: "select"
+ description: "0 为所有人锁定,1 为仅主机可改,2 为所有人可改"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_time_setdawn"
+ display: "旅途权限:设置黎明"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置黎明的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_time_setnoon"
+ display: "旅途权限:设置中午"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置中午的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_time_setdusk"
+ display: "旅途权限:设置黄昏"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置黄昏的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_time_setmidnight"
+ display: "旅途权限:设置午夜"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置午夜的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_godmode"
+ display: "旅途权限:上帝模式"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中上帝模式的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_wind_setstrength"
+ display: "旅途权限:风力强度"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置风力强度的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_rain_setstrength"
+ display: "旅途权限:雨量强度"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置雨量强度的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_time_setspeed"
+ display: "旅途权限:时间速度"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中设置时间速度的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_rain_setfrozen"
+ display: "旅途权限:冻结雨天"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中冻结雨天的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_wind_setfrozen"
+ display: "旅途权限:冻结风"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中冻结风的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_increaseplacementrange"
+ display: "旅途权限:增加放置范围"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中增加放置范围的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_setdifficulty"
+ display: "旅途权限:调整难度"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中调整难度的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_biomespread_setfrozen"
+ display: "旅途权限:冻结生物群系蔓延"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中冻结腐化、猩红等蔓延的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
+ - name: "journeypermission_setspawnrate"
+ display: "旅途权限:调整生成率"
+ default: "2"
+ type: "select"
+ description: "控制旅途模式中调整敌怪生成率的权限"
+ options:
+ - value: "0"
+ label: "锁定"
+ - value: "1"
+ label: "仅主机"
+ - value: "2"
+ label: "所有人"
diff --git a/server/data/games/installgame.json b/server/data/games/installgame.json
index 17d0e0d4..4f3e790e 100644
--- a/server/data/games/installgame.json
+++ b/server/data/games/installgame.json
@@ -166,6 +166,30 @@
"url": "https://store.steampowered.com/app/322330/_/",
"docs": "https://docs.gsm.xiaozhuhouses.asia/%E6%B8%B8%E6%88%8F%E7%99%BE%E7%A7%91/Steam/%E9%A5%A5%E8%8D%92%E9%A3%9F%E7%94%A8%E8%AF%B4%E6%98%8E.html"
},
+ "Terraria": {
+ "game_nameCN": "泰拉瑞亚",
+ "appid": "105600",
+ "tip": "默认端口:7777 TCP。SteamCMD 安装需要使用已拥有 Terraria 的 Steam 账号登录。建议创建实例时使用 serverconfig.txt 启动,例如 Linux 使用 ./TerrariaServer.bin.x86_64 -config serverconfig.txt,Windows 使用 .\\TerrariaServer.exe -config serverconfig.txt,可避免首次启动进入交互式配置流程。",
+ "image": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/105600/header.jpg",
+ "url": "https://store.steampowered.com/app/105600/Terraria/",
+ "docs": "https://docs.gsm.xiaozhuhouses.asia/%E6%B8%B8%E6%88%8F%E7%99%BE%E7%A7%91/Steam/%E6%B3%B0%E6%8B%89%E7%91%9E%E4%BA%9A%E9%A3%9F%E7%94%A8%E8%AF%B4%E6%98%8E.html",
+ "login_anonymous": false,
+ "start_command": {
+ "Windows": ".\\TerrariaServer.exe -config serverconfig.txt",
+ "Linux": "./TerrariaServer.bin.x86_64 -config serverconfig.txt"
+ },
+ "system": [
+ "Windows",
+ "Linux"
+ ],
+ "ports": [
+ {
+ "port": 7777,
+ "protocol": "tcp"
+ }
+ ],
+ "memory": 1
+ },
"Project_Zomboid": {
"game_nameCN": "僵尸毁灭工程",
"appid": "380870",
@@ -836,4 +860,4 @@
"Windows"
]
}
-}
\ No newline at end of file
+}
diff --git a/server/src/routes/gameDeployment.ts b/server/src/routes/gameDeployment.ts
index d04b960b..5c31de9c 100644
--- a/server/src/routes/gameDeployment.ts
+++ b/server/src/routes/gameDeployment.ts
@@ -20,6 +20,8 @@ enum Platform {
MacOS = 'MacOS'
}
+type StartCommandConfig = string | Partial>
+
// 游戏信息接口
interface SteamGameInfo {
game_nameCN: string
@@ -29,6 +31,8 @@ interface SteamGameInfo {
url: string
system?: Platform[]
system_info?: Platform[] // 面板兼容的系统列表
+ login_anonymous?: boolean
+ start_command?: StartCommandConfig
}
// 获取当前平台
@@ -68,6 +72,113 @@ function isPanelCompatibleOnCurrentPlatform(game: SteamGameInfo): boolean {
return game.system_info.includes(currentPlatform)
}
+function getInstallGamePaths(): string[] {
+ const baseDir = process.cwd()
+ return [
+ path.join(baseDir, 'data', 'games', 'installgame.json'), // 打包后的路径
+ path.join(baseDir, 'server', 'data', 'games', 'installgame.json'), // 开发环境路径
+ ]
+}
+
+async function getInstallGameFilePath(): Promise {
+ for (const possiblePath of getInstallGamePaths()) {
+ try {
+ await fs.access(possiblePath)
+ return possiblePath
+ } catch {
+ // 继续尝试下一个路径
+ }
+ }
+
+ return null
+}
+
+async function getInstallGameInfo(gameKey: string): Promise {
+ const gamesFilePath = await getInstallGameFilePath()
+ if (!gamesFilePath) {
+ return null
+ }
+
+ const gamesData = await fs.readFile(gamesFilePath, 'utf-8')
+ const allGames: { [key: string]: SteamGameInfo } = JSON.parse(gamesData)
+ return allGames[gameKey] || null
+}
+
+function resolvePlatformStartCommand(startCommand?: StartCommandConfig): string | null {
+ if (!startCommand) {
+ return null
+ }
+
+ if (typeof startCommand === 'string') {
+ return startCommand.trim() || null
+ }
+
+ const currentPlatform = getCurrentPlatform()
+ return (
+ startCommand[currentPlatform] ||
+ startCommand[Platform.Linux] ||
+ startCommand[Platform.Windows] ||
+ startCommand[Platform.MacOS] ||
+ null
+ )
+}
+
+async function getLocalStartCommandForGame(gameKey: string): Promise {
+ const gameInfo = await getInstallGameInfo(gameKey)
+ return resolvePlatformStartCommand(gameInfo?.start_command)
+}
+
+function normalizeSteamCMDArguments(command: string): string {
+ return command
+ .trim()
+ .replace(/^(?:"[^"]*[\\/]?steamcmd(?:\.exe|\.sh)?"|(?:[a-z]:)?[^\s"]*[\\/]steamcmd(?:\.exe|\.sh)?|steamcmd(?:\.exe|\.sh)?)(?:\s+|$)/i, '')
+ .trim()
+}
+
+function getSteamCMDTokenValue(token: string): string {
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
+ return token.slice(1, -1)
+ }
+
+ return token
+}
+
+function redactSteamCMDCredentials(command: string): string {
+ const tokens = command.match(/"[^"]*"|'(?:''|[^'])*'|\S+/g)
+ if (!tokens) {
+ return command
+ }
+
+ const redactedTokens = [...tokens]
+
+ for (let index = 0; index < redactedTokens.length; index++) {
+ const tokenValue = getSteamCMDTokenValue(redactedTokens[index]).toLowerCase()
+ if (tokenValue !== 'login' && tokenValue !== '+login') {
+ continue
+ }
+
+ const usernameToken = redactedTokens[index + 1]
+ const passwordToken = redactedTokens[index + 2]
+ if (!usernameToken || !passwordToken) {
+ continue
+ }
+
+ const username = getSteamCMDTokenValue(usernameToken).toLowerCase()
+ if (username === 'anonymous' || passwordToken.startsWith('+')) {
+ continue
+ }
+
+ redactedTokens[index + 2] = '******'
+
+ const steamGuardToken = redactedTokens[index + 3]
+ if (steamGuardToken && !steamGuardToken.startsWith('+')) {
+ redactedTokens[index + 3] = '******'
+ }
+ }
+
+ return redactedTokens.join(' ')
+}
+
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -314,11 +425,20 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) =
})
}
- if (!useAnonymous && (!steamUsername || !steamPassword)) {
+ if (!useAnonymous && !steamUsername) {
return res.status(400).json({
success: false,
error: '缺少Steam账户信息',
- message: '非匿名模式下需要提供Steam用户名和密码'
+ message: '非匿名模式下需要提供Steam用户名'
+ })
+ }
+
+ const steamcmdArgs = normalizeSteamCMDArguments(steamcmdCommand)
+ if (!steamcmdArgs) {
+ return res.status(400).json({
+ success: false,
+ error: 'SteamCMD命令无效',
+ message: 'SteamCMD命令必须包含 force_install_dir、login、app_update 等参数'
})
}
@@ -384,10 +504,12 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) =
}
}
+ const redactedSteamcmdArgs = redactSteamCMDCredentials(steamcmdArgs)
+
logger.info(`开始安装游戏: ${gameName || gameKey}`, {
installPath,
appId,
- command: steamcmdCommand,
+ command: redactedSteamcmdArgs,
resetSteamManifest: resetSteamManifest || false
})
@@ -434,20 +556,20 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) =
if (platform === 'win32') {
steamcmdExecutable = '.\\steamcmd.exe'
- fullCommand = `${steamcmdExecutable} ${steamcmdCommand}`
+ fullCommand = `${steamcmdExecutable} ${steamcmdArgs}`
} else {
// Linux环境下确保使用root用户权限执行SteamCMD
steamcmdExecutable = './steamcmd.sh'
// 检查当前用户是否为root,如果不是则使用sudo
const currentUser = process.env.USER || process.env.USERNAME || 'unknown'
if (currentUser === 'root') {
- fullCommand = `${steamcmdExecutable} ${steamcmdCommand}`
+ fullCommand = `${steamcmdExecutable} ${steamcmdArgs}`
} else {
- fullCommand = `sudo -u root ${steamcmdExecutable} ${steamcmdCommand}`
+ fullCommand = `sudo -u root ${steamcmdExecutable} ${steamcmdArgs}`
}
}
- logger.info(`执行SteamCMD命令: ${fullCommand}`, {
+ logger.info(`执行SteamCMD命令: ${redactSteamCMDCredentials(fullCommand)}`, {
platform,
workingDirectory: steamcmdDir
})
@@ -550,11 +672,19 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) =
startCommand = matchedInstance.command
logger.info(`从实例市场找到匹配的启动命令: ${gameNameToMatch} -> ${startCommand}`)
} else {
- logger.info(`实例市场中未找到匹配的游戏: ${gameNameToMatch},使用默认启动命令`)
+ logger.info(`实例市场中未找到匹配的游戏: ${gameNameToMatch},尝试使用本地清单启动命令`)
}
}
} catch (error: any) {
- logger.warn('查询实例市场失败,使用默认启动命令:', error.message)
+ logger.warn('查询实例市场失败,尝试使用本地清单启动命令:', error.message)
+ }
+
+ if (startCommand === 'none') {
+ const localStartCommand = await getLocalStartCommandForGame(gameKey)
+ if (localStartCommand) {
+ startCommand = localStartCommand
+ logger.info(`使用本地清单启动命令: ${gameKey} -> ${startCommand}`)
+ }
}
// 更新实例信息
@@ -657,11 +787,19 @@ router.post('/install', authenticateToken, async (req: Request, res: Response) =
startCommand = matchedInstance.command
logger.info(`从实例市场找到匹配的启动命令: ${gameNameToMatch} -> ${startCommand}`)
} else {
- logger.info(`实例市场中未找到匹配的游戏: ${gameNameToMatch},使用默认启动命令`)
+ logger.info(`实例市场中未找到匹配的游戏: ${gameNameToMatch},尝试使用本地清单启动命令`)
}
}
} catch (error: any) {
- logger.warn('查询实例市场失败,使用默认启动命令:', error.message)
+ logger.warn('查询实例市场失败,尝试使用本地清单启动命令:', error.message)
+ }
+
+ if (startCommand === 'none') {
+ const localStartCommand = await getLocalStartCommandForGame(gameKey)
+ if (localStartCommand) {
+ startCommand = localStartCommand
+ logger.info(`使用本地清单启动命令: ${gameKey} -> ${startCommand}`)
+ }
}
// 创建实例(在安装开始时就创建,而不是等安装完成)
@@ -934,4 +1072,4 @@ router.post('/scan-minecraft-directory', authenticateToken, async (req: Request,
}
})
-export default router
\ No newline at end of file
+export default router