import fs from 'node:fs'; import net from 'node:net'; import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { loadConnectionConfig } from './remote-access-common.mjs'; const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access'); const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json'); const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json'); /** * 解析后端实际监听端口。 * 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。 * 前端 dev server 的 /api 代理必须指向同一端口,否则请求会被转发到空端口。 */ function resolveBackendPort() { const fromEnv = Number(process.env.CANGKU_BACKEND_PORT); if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) { return fromEnv; } const envPath = path.join(ROOT_DIR, 'backend', '.env'); if (fs.existsSync(envPath)) { const matched = fs.readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/mu); const fromFile = Number(matched?.[1]); if (Number.isInteger(fromFile) && fromFile > 0 && fromFile < 65536) { return fromFile; } } return 4001; } export function buildSshArgs(config) { return [ '-N', '-T', '-o', 'BatchMode=yes', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=3', '-o', 'StrictHostKeyChecking=yes', '-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`, '-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`, '-i', path.join(ACCESS_DIR, 'id_ed25519'), '-p', String(config.ssh.port), '-L', `127.0.0.1:${config.localDatabasePort}:${config.ssh.remoteDatabaseHost}:${config.ssh.remoteDatabasePort}`, `${config.ssh.user}@${config.ssh.host}`, ]; } function waitForPort(port, timeoutMs = 60000) { const started = Date.now(); return new Promise((resolve, reject) => { const attempt = () => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.once('connect', () => { socket.destroy(); resolve(); }); socket.once('error', () => { socket.destroy(); if (Date.now() - started >= timeoutMs) { reject(new Error(`SSH 隧道未能在 ${timeoutMs / 1000} 秒内监听端口 ${port}`)); return; } setTimeout(attempt, 250); }); }; attempt(); }); } function waitForFile(filePath) { return new Promise((resolve) => { const timer = setInterval(() => { if (fs.existsSync(filePath)) { clearInterval(timer); resolve(); } }, 500); }); } function waitForExit(child, timeoutMs = 5000) { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); return Promise.race([ new Promise((resolve) => child.once('exit', resolve)), new Promise((resolve) => setTimeout(resolve, timeoutMs)), ]); } export async function main(mode = process.argv[2] || 'tunnel') { if (!['tunnel', 'dev', 'backend'].includes(mode)) { throw new Error(`未知启动模式:${mode}`); } const children = new Set(); let shuttingDown = false; let restartingBackend = false; let backend = null; const stopChildren = () => { shuttingDown = true; for (const child of children) { if (!child.killed) child.kill('SIGTERM'); } }; for (const signal of ['SIGINT', 'SIGTERM']) { process.once(signal, stopChildren); } const startTunnel = async () => { const { value: config } = loadConnectionConfig(ROOT_DIR); const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' }); children.add(tunnel); let ready = false; const earlyExit = new Promise((resolve, reject) => { tunnel.once('exit', (code) => { children.delete(tunnel); if (!ready) { reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`)); } else if (!shuttingDown) { console.error(`SSH 隧道已退出(${code ?? 'unknown'})`); stopChildren(); process.exitCode = code || 1; } }); }); try { await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]); ready = true; } catch (error) { if (!tunnel.killed) tunnel.kill('SIGTERM'); throw error; } console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`); return tunnel; }; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const startApplication = (args, kind) => { const childEnvironment = { ...process.env, CANGKU_REMOTE_SUPERVISOR: 'true', }; if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) { childEnvironment.VITE_API_PROXY_TARGET = `http://localhost:${resolveBackendPort()}`; } const child = spawn(npmCommand, args, { cwd: ROOT_DIR, stdio: 'inherit', env: childEnvironment, // Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL shell: process.platform === 'win32', }); children.add(child); child.once('exit', (code) => { children.delete(child); if (kind === 'backend' && restartingBackend) return; if (!shuttingDown) { console.error(`${kind === 'backend' ? '后端' : '前端'}进程已退出(${code ?? 'unknown'})`); stopChildren(); process.exitCode = code || 1; } }); return child; }; const hasConnection = fs.existsSync(CONNECTION_PATH); if (hasConnection) { if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH); await startTunnel(); } else if (mode === 'tunnel') { throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key'); } if (mode === 'tunnel') return; backend = startApplication( ['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'], 'backend' ); if (mode === 'dev') { startApplication(['--prefix', 'frontend', 'run', 'dev'], 'frontend'); } if (!hasConnection) { console.log('首次启动:请在安装页面输入配置请求地址和 API Key。'); await waitForFile(RESTART_MARKER_PATH); restartingBackend = true; if (!backend.killed) backend.kill('SIGTERM'); await waitForExit(backend); restartingBackend = false; fs.unlinkSync(RESTART_MARKER_PATH); await startTunnel(); backend = startApplication( ['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'], 'backend' ); } } if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((error) => { console.error(`远程运行启动失败:${error.message}`); process.exitCode = 1; }); }