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'); /** * 解析 ssh 可执行文件路径。 * 隧道子进程是按「裸命令名 ssh」拉起的,依赖 PATH —— 而计划任务 / 非交互式 * 启动时 PATH 往往不含 OpenSSH 目录,隧道就会直接 spawn ENOENT, * 表现为「服务起不来但日志没线索」。这里改成绝对路径优先,找不到再退回裸名。 */ function resolveSshBinary() { const candidates = [ process.env.CANGKU_SSH, 'C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'C:\\Program Files\\OpenSSH-Win64\\ssh.exe', 'C:\\Program Files (x86)\\OpenSSH-Win64\\ssh.exe', ].filter(Boolean); for (const candidate of candidates) { try { if (fs.existsSync(candidate)) return candidate; } catch { /* 忽略不可访问的候选路径 */ } } return 'ssh'; } const SSH_BINARY = resolveSshBinary(); /** * 解析后端实际监听端口。 * 优先级: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; } /** * 隧道重连退避:1s → 2s → 4s … 封顶 30s。 * 首次失败也立即重试(等价 autossh 的 AUTOSSH_GATETIME=0),避免网络尚未就绪时白等。 */ const TUNNEL_RETRY_BASE_MS = 1000; const TUNNEL_RETRY_MAX_MS = 30000; const TUNNEL_RETRY_EXPONENT_CAP = 10; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export function computeTunnelRetryDelay(attempt) { const exponent = Math.min(attempt, TUNNEL_RETRY_EXPONENT_CAP); return Math.min(TUNNEL_RETRY_BASE_MS * 2 ** exponent, TUNNEL_RETRY_MAX_MS); } export function buildSshArgs(config) { // 隧道稳定性:这条隧道承载「系统配置库」,一旦掉线监督器会连带重启后端 // (约 20~90 秒不可用)。日志里高发的 `client_loop: send disconnect: Connection reset` // 属于长时间空闲被中间设备掐断,因此这里缩短保活探测间隔、开启 TCPKeepAlive, // 并给建连加超时,避免连接挂在半死状态。 return [ '-N', '-T', '-o', 'BatchMode=yes', // 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止; // 真正的根治是下面的退避重连循环。 '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', '-o', 'TCPKeepAlive=yes', '-o', 'ConnectTimeout=10', '-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); } let tunnelRetryAttempt = 0; let resolveTunnelReady = null; const tunnelReady = new Promise((resolve) => { resolveTunnelReady = resolve; }); /** * 启动一条 SSH 隧道并等它监听就绪。 * 返回 { tunnel, exitPromise };隧道结束由 exitPromise 上报退出码,交给 * superviseTunnel 决定重连 —— 这里**不再**牵连后端/前端。 */ const startTunnel = async () => { const { value: config } = loadConnectionConfig(ROOT_DIR); const tunnel = spawn(SSH_BINARY, buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit', // Windows 下必须显式隐藏:父进程没有控制台时,spawn 一个控制台程序 // 会让系统新分配一个控制台窗口(就是那个反复弹出的 ssh.exe 黑窗)。 // 隧道每 60 秒重连一次,不加这个就会一直弹。 windowsHide: true, }); children.add(tunnel); const exitPromise = new Promise((resolve) => { tunnel.once('exit', (code) => { children.delete(tunnel); resolve(code); }); }); // 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection const outcome = await Promise.race([ waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })), exitPromise.then((code) => ({ ready: false, code })), ]); if (!outcome.ready) { if (!tunnel.killed) tunnel.kill('SIGTERM'); throw new Error(`SSH 隧道在建立前退出(${outcome.code ?? 'unknown'})`); } console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`); return { tunnel, exitPromise }; }; /** * 隧道自愈循环(Windows 版 autossh 的语义)。 * * 隧道断开只重连隧道本身,后端/前端进程保持不动。这是原先缺失的容错: * 旧实现在隧道 exit 时直接 stopChildren(),把整个开发栈一起杀掉,于是每一次 * 远端 RST 都升级成一次「网站打不开」,还得靠外部守护清残留、整树重启。 * * 建立失败走指数退避(1s→30s);建立成功后重置退避,断开则固定 1s 后重连。 */ const superviseTunnel = async () => { for (;;) { if (shuttingDown) return; let session; try { session = await startTunnel(); tunnelRetryAttempt = 0; if (resolveTunnelReady) { resolveTunnelReady(); resolveTunnelReady = null; } } catch (error) { if (shuttingDown) return; const delay = computeTunnelRetryDelay(tunnelRetryAttempt); tunnelRetryAttempt += 1; console.error(`SSH 隧道建立失败(${error.message}),${delay}ms 后重试`); await sleep(delay); continue; } const code = await session.exitPromise; if (shuttingDown) return; console.error(`SSH 隧道已退出(${code ?? 'unknown'}),正在重连…(后端/前端不受影响)`); await sleep(TUNNEL_RETRY_BASE_MS); } }; // 守护循环本身理论上不抛错,兜底仍保留:真异常时按老路径收尾,避免留下半死进程 const startTunnelSupervisor = () => { superviseTunnel().catch((error) => { console.error(`隧道守护异常终止:${error.message}`); stopChildren(); process.exitCode = 1; }); }; 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', // 同理:避免 cmd.exe / node 弹出控制台窗口 windowsHide: true, }); 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); startTunnelSupervisor(); await tunnelReady; } 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); startTunnelSupervisor(); await tunnelReady; 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; }); }