diff --git a/.gitignore b/.gitignore index e75da8b9..053a7248 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,12 @@ favorites.json server/data/environment server/data/wallpapers/ server/data/lib/ +server/data/tunnels/ +server/data/tools/ data/wallpapers/ data/lib/ +data/tunnels/ +data/tools/ dist/ @@ -144,4 +148,4 @@ src/public/1.png .turbo 参考文件/ -.报错.txt \ No newline at end of file +.报错.txt diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index f7b99ea5..6b3e89f8 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -31,6 +31,7 @@ import { HelpCircle, CheckCircle, Coffee, + Puzzle, ChevronLeft, ChevronRight } from 'lucide-react' @@ -67,7 +68,7 @@ const Layout: React.FC = ({ children }) => { { name: '定时任务', href: '/scheduled-tasks', icon: Clock }, { name: '文件管理', href: '/files', icon: FolderOpen }, { name: '环境管理', href: '/environment', icon: Coffee }, - // { name: '插件', href: '/plugins', icon: Puzzle }, + { name: '插件', href: '/plugins', icon: Puzzle }, { name: '设置', href: '/settings', icon: Settings }, { name: '关于项目', href: '/about', icon: Info }, ] @@ -869,4 +870,4 @@ const Layout: React.FC = ({ children }) => { ) } -export default Layout \ No newline at end of file +export default Layout 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 = () => {
setSteamPassword(e.target.value)} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white" - placeholder="输入Steam密码" + placeholder="留空则在终端中输入" /> +

+ 留空时 SteamCMD 会在终端提示输入密码和 Steam Guard 码 +

)} @@ -5844,18 +5869,18 @@ const GameDeploymentPage: React.FC = () => {
+ 每行一个 peer。未填写时只启动本地节点,通常无法自动发现其它节点。 + +
+ +
+ + +
+
+

已有配置

+ +
+
+

正在加载配置...

+
+ +

配置预览

+
+
配置名称-
+
工具-
+
关联实例-
+
连接地址-
+
+

+
+        

生成结果

+
+
配置目录-
+
启动命令-
+
连接地址-
+
+

+      
+ + + + + + diff --git a/server/data/plugins/tunnel-helper/plugin.json b/server/data/plugins/tunnel-helper/plugin.json new file mode 100644 index 00000000..8be95dab --- /dev/null +++ b/server/data/plugins/tunnel-helper/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "tunnel-helper", + "displayName": "内网穿透助手", + "description": "为任意游戏实例生成 frp 或 EasyTier 穿透配置,并创建独立的穿透实例。", + "version": "0.1.0", + "author": "GSM3 Community", + "enabled": true, + "hasWebInterface": true, + "entryPoint": "index.html", + "icon": "network", + "category": "工具" +} \ No newline at end of file diff --git a/server/src/modules/plugin/PluginManager.ts b/server/src/modules/plugin/PluginManager.ts index c84fc2d5..9a1a228f 100644 --- a/server/src/modules/plugin/PluginManager.ts +++ b/server/src/modules/plugin/PluginManager.ts @@ -1,4 +1,5 @@ import { promises as fs } from 'fs' +import fsSync from 'fs' import path from 'path' import { fileURLToPath } from 'url' import winston from 'winston' @@ -39,8 +40,22 @@ export class PluginManager { constructor(logger: winston.Logger) { this.logger = logger - // this.pluginsDir = path.join(__dirname, '../../../data/plugins') - this.pluginsDir = path.join(__dirname, '../../data/plugins') + const baseDir = process.cwd() + const possiblePaths = [ + path.join(baseDir, 'data', 'plugins'), // 打包后的路径 + path.join(baseDir, 'server', 'data', 'plugins'), // 开发环境路径 + path.join(__dirname, '../../data/plugins'), // dist/modules/plugin -> data/plugins + path.join(__dirname, '../../../data/plugins') // src/modules/plugin -> server/data/plugins + ] + + this.pluginsDir = possiblePaths[0] + for (const possiblePath of possiblePaths) { + if (fsSync.existsSync(possiblePath)) { + this.pluginsDir = possiblePath + break + } + } + this.initializePluginsDirectory() } @@ -317,4 +332,4 @@ export class PluginManager { this.plugins.clear() this.logger.info('PluginManager 已清理') } -} \ No newline at end of file +} diff --git a/server/src/modules/terminal/TerminalManager.ts b/server/src/modules/terminal/TerminalManager.ts index 8a154600..4c189155 100755 --- a/server/src/modules/terminal/TerminalManager.ts +++ b/server/src/modules/terminal/TerminalManager.ts @@ -117,7 +117,8 @@ export class TerminalManager { */ public async createPty(socket: Socket, data: CreatePtyData): Promise { try { - const { sessionId, name, cols, rows, workingDirectory = process.cwd(), enableStreamForward = false, programPath, autoCloseOnForwardExit = false, terminalUser } = data + const { sessionId, name, cols, rows, workingDirectory: rawWorkingDirectory = process.cwd(), enableStreamForward = false, programPath, autoCloseOnForwardExit = false, terminalUser } = data + const workingDirectory = path.resolve(rawWorkingDirectory) const sessionName = name || `终端会话 ${sessionId.slice(-8)}` // 获取终端配置和默认用户(提升到方法开始处) @@ -1516,6 +1517,7 @@ export class TerminalManager { */ private async createPtyFallback(sessionId: string, sessionName: string, workingDirectory: string, socket: Socket, enableStreamForward?: boolean, programPath?: string, autoCloseOnForwardExit?: boolean): Promise { try { + workingDirectory = path.resolve(workingDirectory) this.logger.info(`使用当前用户创建PTY回退会话: ${sessionId}`) // 构建PTY命令参数,使用当前用户 diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 6efb7390..f52d260c 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -156,6 +156,13 @@ const fixWindowsPath = (filePath: string): string => { return decodedPath } +const resolveFilesystemPath = (filePath: string): string => { + const fixedPath = fixWindowsPath(filePath) + return path.isAbsolute(fixedPath) + ? fixedPath + : path.resolve(process.cwd(), fixedPath) +} + type BomEncoding = 'utf-8' | 'utf-16le' | 'utf-16be' | null const normalizeTextEncoding = (encoding?: string | null): string => { @@ -301,6 +308,58 @@ const prependEncodingBom = (buffer: Buffer, encoding: string, bomEncoding: BomEn return buffer } +// 将实例工作目录等相对路径解析为文件管理器可打开的绝对路径 +router.get('/resolve-path', authenticateToken, async (req: Request, res: Response) => { + try { + const { path: rawPath } = req.query + + if (!rawPath || typeof rawPath !== 'string') { + return res.status(400).json({ + success: false, + message: '缺少路径参数' + }) + } + + if (rawPath.includes('\0')) { + return res.status(400).json({ + success: false, + message: '无效的路径' + }) + } + + const fixedPath = fixWindowsPath(rawPath) + const resolvedPath = resolveFilesystemPath(rawPath) + let exists = false + let type: 'file' | 'directory' | undefined + + try { + const stats = await fs.stat(resolvedPath) + exists = true + type = stats.isDirectory() ? 'directory' : 'file' + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error + } + } + + res.json({ + success: true, + data: { + originalPath: rawPath, + resolvedPath, + isAbsolute: path.isAbsolute(fixedPath), + exists, + type + } + }) + } catch (error: any) { + res.status(500).json({ + success: false, + message: error.message || '路径解析失败' + }) + } +}) + // 获取目录列表 router.get('/list', authenticateToken, async (req: Request, res: Response) => { try { 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 diff --git a/server/src/routes/pluginApi.ts b/server/src/routes/pluginApi.ts index 03eab01d..15d8423e 100644 --- a/server/src/routes/pluginApi.ts +++ b/server/src/routes/pluginApi.ts @@ -1,4 +1,10 @@ import { Router, Request, Response } from 'express' +import fs from 'fs/promises' +import { constants as fsConstants, createReadStream, createWriteStream } from 'fs' +import https from 'https' +import path from 'path' +import * as tar from 'tar' +import crypto from 'crypto' import { authenticateToken } from '../middleware/auth.js' import type { InstanceManager } from '../modules/instance/InstanceManager.js' import type { SystemManager } from '../modules/system/SystemManager.js' @@ -7,6 +13,8 @@ import type { GameManager } from '../modules/game/GameManager.js' import filesRouter from './files.js' import { setupTerminalRoutes } from './terminal.js' import logger from '../utils/logger.js' +import { createTarSecurityFilter } from '../utils/tarSecurityFilter.js' +import { zipToolsManager } from '../utils/zipToolsManager.js' const router = Router() @@ -16,6 +24,670 @@ let systemManager: SystemManager let terminalManager: TerminalManager let gameManager: GameManager +type TunnelToolName = 'frp' | 'easytier' + +interface GitHubReleaseAsset { + name: string + browser_download_url: string + size?: number + digest?: string +} + +interface GitHubRelease { + tag_name: string + html_url: string + assets: GitHubReleaseAsset[] +} + +interface TunnelToolDefinition { + name: TunnelToolName + label: string + repo: string + executableNames: string[] +} + +interface TunnelToolInstallResult { + tool: TunnelToolName + version: string + assetName: string + installPath: string + executablePath: string + releaseUrl: string +} + +const TUNNEL_TOOL_DEFINITIONS: Record = { + frp: { + name: 'frp', + label: 'frp', + repo: 'fatedier/frp', + executableNames: process.platform === 'win32' ? ['frpc.exe'] : ['frpc'] + }, + easytier: { + name: 'easytier', + label: 'EasyTier', + repo: 'EasyTier/EasyTier', + executableNames: process.platform === 'win32' ? ['easytier-core.exe'] : ['easytier-core'] + } +} + +const getTunnelToolDefinition = (tool: string): TunnelToolDefinition => { + if (tool === 'frp' || tool === 'easytier') { + return TUNNEL_TOOL_DEFINITIONS[tool] + } + + throw new Error(`不支持的工具: ${tool}`) +} + +const getErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error) +} + +const stripShellQuotes = (value: string): string => { + const trimmed = value.trim() + if (trimmed.length >= 2) { + const first = trimmed[0] + const last = trimmed[trimmed.length - 1] + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return trimmed.slice(1, -1) + } + } + + return trimmed +} + +const looksLikePath = (value: string): boolean => { + return value.includes('/') || value.includes('\\') || /^[A-Za-z]:/.test(value) +} + +const getWindowsExecutableExtensions = (): string[] => { + const pathext = process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD' + return pathext + .split(';') + .map(ext => ext.trim()) + .filter(Boolean) +} + +const getExecutableCandidates = (executable: string, workingDirectory?: string): string[] => { + const command = stripShellQuotes(executable) + if (!command) return [] + + const candidates = new Set() + const accessNames = process.platform === 'win32' && !path.extname(command) + ? [command, ...getWindowsExecutableExtensions().map(ext => `${command}${ext}`)] + : [command] + + if (looksLikePath(command)) { + const baseDirectory = workingDirectory + ? path.resolve(stripShellQuotes(workingDirectory)) + : process.cwd() + + for (const accessName of accessNames) { + candidates.add(path.isAbsolute(accessName) + ? accessName + : path.resolve(baseDirectory, accessName)) + } + + return Array.from(candidates) + } + + const pathEntries = (process.env.PATH || '') + .split(path.delimiter) + .map(entry => entry.trim()) + .filter(Boolean) + + for (const entry of pathEntries) { + for (const accessName of accessNames) { + candidates.add(path.join(entry, accessName)) + } + } + + return Array.from(candidates) +} + +const findExecutable = async (executable: string, workingDirectory?: string): Promise => { + const mode = process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK + + for (const candidate of getExecutableCandidates(executable, workingDirectory)) { + try { + await fs.access(candidate, mode) + return candidate + } catch { + // 继续检查下一个候选路径 + } + } + + return null +} + +const getPlatformAliases = (): string[] => { + switch (process.platform) { + case 'win32': + return ['windows', 'win32', 'win'] + case 'linux': + return ['linux'] + case 'darwin': + return ['darwin', 'macos', 'mac'] + case 'freebsd': + return ['freebsd'] + default: + return [process.platform] + } +} + +const getFrpArchAliases = (): string[] => { + switch (process.arch) { + case 'x64': + return ['amd64', 'x86_64', 'x64'] + case 'arm64': + return ['arm64', 'aarch64'] + case 'arm': + return ['arm', 'arm_hf'] + default: + return [process.arch] + } +} + +const getEasyTierArchAliases = (): string[] => { + switch (process.arch) { + case 'x64': + return ['x86_64', 'amd64', 'x64'] + case 'arm64': + return ['aarch64', 'arm64'] + case 'arm': + return ['armv7', 'arm'] + default: + return [process.arch] + } +} + +const isSupportedArchive = (assetName: string): boolean => { + const name = assetName.toLowerCase() + return name.endsWith('.zip') || name.endsWith('.tar.gz') || name.endsWith('.tgz') +} + +const scoreAsset = (tool: TunnelToolName, asset: GitHubReleaseAsset): number => { + const name = asset.name.toLowerCase() + if (!isSupportedArchive(name)) return -1 + + const platformAliases = getPlatformAliases() + const archAliases = tool === 'frp' ? getFrpArchAliases() : getEasyTierArchAliases() + const hasPlatform = platformAliases.some(alias => name.includes(alias)) + const hasArch = archAliases.some(alias => name.includes(alias)) + if (!hasPlatform || !hasArch) return -1 + + let score = 100 + if (tool === 'frp') { + if (!name.startsWith('frp_')) return -1 + if (name.includes('android')) return -1 + if (name.includes(`_${platformAliases[0]}_${archAliases[0]}`)) score += 20 + } else { + if (!name.startsWith('easytier-')) return -1 + if (name.includes('gui') || name.includes('app-') || name.endsWith('.apk')) return -1 + if (name.endsWith('.rpm') || name.endsWith('.dmg') || name.endsWith('.appimage')) return -1 + if (name.includes(`${platformAliases[0]}-${archAliases[0]}`)) score += 20 + } + + if (name.endsWith('.zip')) score += process.platform === 'win32' ? 8 : 2 + if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) score += process.platform === 'win32' ? 2 : 8 + + return score +} + +const selectTunnelToolAsset = (tool: TunnelToolName, release: GitHubRelease): GitHubReleaseAsset => { + const candidates = release.assets + .map(asset => ({ asset, score: scoreAsset(tool, asset) })) + .filter(item => item.score >= 0) + .sort((a, b) => b.score - a.score) + + if (candidates.length === 0) { + throw new Error(`未找到适用于 ${process.platform}/${process.arch} 的 ${tool} release 资产`) + } + + return candidates[0].asset +} + +const safeVersionSegment = (version: string): string => { + return version.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +const getTunnelToolsRoot = (): string => { + return path.resolve(process.cwd(), 'data', 'tools', 'tunnel-helper') +} + +const getPluginDataRoot = (): string => { + return path.resolve(process.cwd(), 'data') +} + +const assertInsideDirectory = (targetPath: string, rootPath: string): void => { + const resolvedTarget = path.resolve(targetPath) + const resolvedRoot = path.resolve(rootPath) + const relative = path.relative(resolvedRoot, resolvedTarget) + + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`路径超出允许范围: ${targetPath}`) + } +} + +const resolvePluginDataPath = (dataPath: string): string => { + if (!dataPath || typeof dataPath !== 'string') { + throw new Error('缺少 dataPath') + } + + if (dataPath.includes('\0')) { + throw new Error('无效的路径') + } + + if (path.isAbsolute(dataPath)) { + throw new Error('dataPath 必须是相对 data 目录的路径') + } + + const dataRoot = getPluginDataRoot() + const resolvedPath = path.resolve(dataRoot, dataPath) + assertInsideDirectory(resolvedPath, dataRoot) + return resolvedPath +} + +const requestJson = async (url: string): Promise => { + return new Promise((resolve, reject) => { + https.get(url, { + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'GameServerManager-TunnelHelper' + } + }, response => { + let body = '' + + response.on('data', chunk => { + body += chunk + }) + + response.on('end', () => { + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + try { + const parsed = JSON.parse(body) + reject(new Error(parsed.message || `HTTP ${response.statusCode}`)) + } catch { + reject(new Error(`HTTP ${response.statusCode}`)) + } + return + } + + try { + resolve(JSON.parse(body) as T) + } catch (error) { + reject(error) + } + }) + }).on('error', reject) + }) +} + +const requestHeaders = async (url: string): Promise<{ + statusCode?: number + headers: Record +}> => { + return new Promise((resolve, reject) => { + const request = https.request(url, { + method: 'HEAD', + headers: { 'User-Agent': 'GameServerManager-TunnelHelper' } + }, response => { + response.resume() + resolve({ + statusCode: response.statusCode, + headers: response.headers as Record + }) + }) + + request.on('error', reject) + request.end() + }) +} + +const requestText = async (url: string, redirectCount = 0): Promise => { + if (redirectCount > 5) { + throw new Error('请求重定向次数过多') + } + + return new Promise((resolve, reject) => { + https.get(url, { + headers: { 'User-Agent': 'GameServerManager-TunnelHelper' } + }, response => { + if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume() + requestText(new URL(response.headers.location, url).toString(), redirectCount + 1) + .then(resolve) + .catch(reject) + return + } + + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + response.resume() + reject(new Error(`HTTP ${response.statusCode || 'unknown'}`)) + return + } + + response.setEncoding('utf8') + let body = '' + response.on('data', chunk => { + body += chunk + }) + response.on('end', () => resolve(body)) + }).on('error', reject) + }) +} + +const decodeHtmlEntities = (value: string): string => { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') +} + +const extractTagFromReleaseLocation = (location: string): string | null => { + const match = location.match(/\/releases\/tag\/([^/?#]+)/) + return match ? decodeURIComponent(match[1]) : null +} + +const fetchLatestReleaseTagWithoutApi = async (repo: string): Promise => { + const latestUrl = `https://github.com/${repo}/releases/latest` + const response = await requestHeaders(latestUrl) + const locationHeader = response.headers.location + const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader + + if (location) { + const tag = extractTagFromReleaseLocation(location) + if (tag) return tag + } + + throw new Error(`无法从 ${latestUrl} 的跳转响应中获取最新版本`) +} + +const extractReleaseAssetsFromHtml = (repo: string, html: string): GitHubReleaseAsset[] => { + const assets: GitHubReleaseAsset[] = [] + const seen = new Set() + const expectedPathPrefix = `/${repo.toLowerCase()}/releases/download/` + const hrefRegex = /href="([^"]*\/releases\/download\/[^"]+)"/g + let match: RegExpExecArray | null + + while ((match = hrefRegex.exec(html)) !== null) { + const href = decodeHtmlEntities(match[1]) + let assetUrl: URL + + try { + assetUrl = new URL(href, 'https://github.com') + } catch { + continue + } + + if (assetUrl.hostname !== 'github.com') continue + if (!assetUrl.pathname.toLowerCase().startsWith(expectedPathPrefix)) continue + + const encodedName = assetUrl.pathname.split('/').pop() + if (!encodedName) continue + + const name = decodeURIComponent(encodedName) + if (seen.has(name)) continue + + const assetHtml = html.slice(match.index, match.index + 3000) + const digestMatch = assetHtml.match(/value="(sha256:[a-fA-F0-9]{64})"/) + + seen.add(name) + assets.push({ + name, + browser_download_url: assetUrl.toString(), + digest: digestMatch ? digestMatch[1].toLowerCase() : undefined + }) + } + + return assets +} + +const fetchTunnelToolReleaseWithoutApi = async (definition: TunnelToolDefinition): Promise => { + const tag = await fetchLatestReleaseTagWithoutApi(definition.repo) + const expandedAssetsUrl = `https://github.com/${definition.repo}/releases/expanded_assets/${encodeURIComponent(tag)}` + const assetsHtml = await requestText(expandedAssetsUrl) + const assets = extractReleaseAssetsFromHtml(definition.repo, assetsHtml) + + if (assets.length === 0) { + throw new Error(`未能从 GitHub Release 页面读取 ${definition.label} 资产列表`) + } + + return { + tag_name: tag, + html_url: `https://github.com/${definition.repo}/releases/tag/${encodeURIComponent(tag)}`, + assets + } +} + +const fetchTunnelToolReleaseWithApi = async (definition: TunnelToolDefinition): Promise => { + return requestJson(`https://api.github.com/repos/${definition.repo}/releases/latest`) +} + +const fetchLatestTunnelToolRelease = async (definition: TunnelToolDefinition): Promise => { + try { + return await fetchTunnelToolReleaseWithoutApi(definition) + } catch (webError) { + logger.warn(`插件通过 GitHub 页面获取 ${definition.label} 最新版本失败,尝试 GitHub API:`, webError) + + try { + return await fetchTunnelToolReleaseWithApi(definition) + } catch (apiError) { + throw new Error( + `获取 ${definition.label} 最新版本失败: 页面方式失败: ${getErrorMessage(webError)}; GitHub API 失败: ${getErrorMessage(apiError)}` + ) + } + } +} + +const downloadFile = async ( + url: string, + targetPath: string, + onProgress?: (progress: number) => void, + redirectCount = 0 +): Promise => { + if (redirectCount > 5) { + throw new Error('下载重定向次数过多') + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }) + + return new Promise((resolve, reject) => { + const request = https.get(url, { + headers: { 'User-Agent': 'GameServerManager-TunnelHelper' } + }, response => { + if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume() + downloadFile(new URL(response.headers.location, url).toString(), targetPath, onProgress, redirectCount + 1) + .then(resolve) + .catch(reject) + return + } + + if (response.statusCode !== 200) { + response.resume() + reject(new Error(`下载失败: HTTP ${response.statusCode}`)) + return + } + + const totalSize = Number(response.headers['content-length'] || 0) + let downloadedSize = 0 + const file = createWriteStream(targetPath) + + response.on('data', chunk => { + downloadedSize += chunk.length + if (totalSize > 0) { + onProgress?.(Math.round((downloadedSize / totalSize) * 100)) + } + }) + + response.pipe(file) + + file.on('finish', () => { + file.close() + resolve() + }) + + file.on('error', error => { + fs.rm(targetPath, { force: true }).catch(() => { }) + reject(error) + }) + }) + + request.on('error', error => { + fs.rm(targetPath, { force: true }).catch(() => { }) + reject(error) + }) + }) +} + +const calculateSha256 = async (filePath: string): Promise => { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256') + const stream = createReadStream(filePath) + + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) + }) +} + +const verifyDownloadedAsset = async (asset: GitHubReleaseAsset, archivePath: string): Promise => { + const stats = await fs.stat(archivePath) + if (stats.size <= 0) { + throw new Error('下载的文件为空') + } + + if (asset.digest && asset.digest.startsWith('sha256:')) { + const expected = asset.digest.slice('sha256:'.length).toLowerCase() + const actual = await calculateSha256(archivePath) + if (actual !== expected) { + throw new Error(`SHA256 校验失败: expected=${expected}, actual=${actual}`) + } + } +} + +const extractTunnelToolArchive = async (archivePath: string, targetPath: string): Promise => { + const lowerArchivePath = archivePath.toLowerCase() + if (lowerArchivePath.endsWith('.zip')) { + await zipToolsManager.extractZip(archivePath, targetPath) + return + } + + if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz')) { + await tar.extract({ + file: archivePath, + cwd: targetPath, + filter: createTarSecurityFilter({ cwd: targetPath }) + } as any) + return + } + + throw new Error(`不支持的压缩包格式: ${path.basename(archivePath)}`) +} + +const findExtractedExecutable = async (rootPath: string, executableNames: string[]): Promise => { + const queue = [rootPath] + const expectedNames = new Set(executableNames.map(name => name.toLowerCase())) + + while (queue.length > 0) { + const currentPath = queue.shift()! + const entries = await fs.readdir(currentPath, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(currentPath, entry.name) + if (entry.isDirectory()) { + queue.push(entryPath) + continue + } + + if (entry.isFile() && expectedNames.has(entry.name.toLowerCase())) { + if (process.platform !== 'win32') { + await fs.chmod(entryPath, 0o755).catch(() => { }) + } + return entryPath + } + } + } + + return null +} + +const installTunnelTool = async ( + tool: TunnelToolName, + onProgress: (progress: number) => void, + onStatusChange: (status: string) => void +): Promise => { + const definition = getTunnelToolDefinition(tool) + const toolsRoot = getTunnelToolsRoot() + + onStatusChange(`正在获取 ${definition.label} 最新版本信息...`) + const release = await fetchLatestTunnelToolRelease(definition) + const asset = selectTunnelToolAsset(tool, release) + + const version = safeVersionSegment(release.tag_name) + const toolRoot = path.join(toolsRoot, definition.name) + const installPath = path.join(toolRoot, version) + const downloadPath = path.join(toolRoot, 'downloads', asset.name) + const stagingPath = path.join(toolRoot, `.staging-${version}-${Date.now()}`) + + assertInsideDirectory(toolRoot, toolsRoot) + assertInsideDirectory(installPath, toolsRoot) + assertInsideDirectory(downloadPath, toolsRoot) + assertInsideDirectory(stagingPath, toolsRoot) + + try { + await fs.mkdir(path.dirname(downloadPath), { recursive: true }) + + onStatusChange(`正在下载 ${asset.name}...`) + await downloadFile(asset.browser_download_url, downloadPath, progress => { + onProgress(Math.min(70, Math.round(progress * 0.7))) + }) + + onStatusChange('正在校验下载文件...') + await verifyDownloadedAsset(asset, downloadPath) + + await fs.rm(stagingPath, { recursive: true, force: true }) + await fs.mkdir(stagingPath, { recursive: true }) + + onStatusChange('正在解压文件...') + await extractTunnelToolArchive(downloadPath, stagingPath) + onProgress(90) + + const executablePath = await findExtractedExecutable(stagingPath, definition.executableNames) + if (!executablePath) { + throw new Error(`安装验证失败:未找到 ${definition.executableNames.join(' 或 ')}`) + } + + await fs.rm(installPath, { recursive: true, force: true }) + await fs.rename(stagingPath, installPath) + await fs.rm(downloadPath, { force: true }).catch(() => { }) + + const finalExecutablePath = path.join(installPath, path.relative(stagingPath, executablePath)) + const resolvedExecutable = await findExecutable(finalExecutablePath) + if (!resolvedExecutable) { + throw new Error(`安装验证失败:${finalExecutablePath} 不可访问`) + } + + onProgress(100) + onStatusChange('安装完成') + + return { + tool, + version: release.tag_name, + assetName: asset.name, + installPath, + executablePath: finalExecutablePath, + releaseUrl: release.html_url + } + } catch (error) { + await fs.rm(stagingPath, { recursive: true, force: true }).catch(() => { }) + await fs.rm(downloadPath, { force: true }).catch(() => { }) + throw error + } +} + export function setPluginApiDependencies( instManager: InstanceManager, sysManager: SystemManager, @@ -576,6 +1248,124 @@ router.get('/games', async (req: Request, res: Response) => { } }) +// ==================== 工具检测API ==================== + +router.post('/tools/resolve-executable', async (req: Request, res: Response) => { + try { + const { executable, workingDirectory } = req.body || {} + + if (!executable || typeof executable !== 'string') { + return res.status(400).json({ + success: false, + message: '缺少可执行文件参数' + }) + } + + if (executable.includes('\0') || (typeof workingDirectory === 'string' && workingDirectory.includes('\0'))) { + return res.status(400).json({ + success: false, + message: '无效的路径参数' + }) + } + + const resolvedPath = await findExecutable( + executable, + typeof workingDirectory === 'string' ? workingDirectory : undefined + ) + + res.json({ + success: true, + data: { + executable: stripShellQuotes(executable), + found: Boolean(resolvedPath), + resolvedPath + } + }) + } catch (error) { + logger.error('插件检测可执行文件失败:', error) + res.status(500).json({ + success: false, + message: '检测可执行文件失败', + error: error instanceof Error ? error.message : '未知错误' + }) + } +}) + +router.post('/tools/resolve-data-path', async (req: Request, res: Response) => { + const { dataPath } = req.body || {} + + try { + const absolutePath = resolvePluginDataPath(String(dataPath || '')) + + res.json({ + success: true, + data: { + dataPath, + absolutePath + } + }) + } catch (error) { + logger.error('插件解析 data 路径失败:', error) + res.status(400).json({ + success: false, + message: '解析 data 路径失败', + error: error instanceof Error ? error.message : '未知错误' + }) + } +}) + +router.post('/tools/install-tunnel-tool', async (req: Request, res: Response) => { + const { tool } = req.body || {} + + try { + const definition = getTunnelToolDefinition(String(tool || '')) + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': process.env.CORS_ORIGIN || '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + }) + + const sendEvent = (event: string, data: any) => { + res.write(`event: ${event}\n`) + res.write(`data: ${JSON.stringify(data)}\n\n`) + } + + try { + const result = await installTunnelTool( + definition.name, + progress => sendEvent('progress', { progress }), + status => sendEvent('status', { status }) + ) + + sendEvent('complete', { + success: true, + message: `${definition.label} 安装完成`, + data: result + }) + res.end() + } catch (error) { + logger.error(`插件安装 ${definition.label} 失败:`, error) + sendEvent('error', { + success: false, + message: `${definition.label} 安装失败`, + error: error instanceof Error ? error.message : '未知错误' + }) + res.end() + } + } catch (error) { + logger.error('插件安装穿透工具请求失败:', error) + if (!res.headersSent) { + res.status(400).json({ + success: false, + message: error instanceof Error ? error.message : '安装请求无效' + }) + } + } +}) + // ==================== 文件操作API ==================== // 转发文件操作请求到files路由 @@ -608,4 +1398,4 @@ router.get('/health', (req: Request, res: Response) => { }) }) -export default router \ No newline at end of file +export default router