187 lines
6.0 KiB
JavaScript
187 lines
6.0 KiB
JavaScript
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');
|
||
|
||
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://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`;
|
||
}
|
||
const child = spawn(npmCommand, args, {
|
||
cwd: ROOT_DIR,
|
||
stdio: 'inherit',
|
||
env: childEnvironment,
|
||
});
|
||
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;
|
||
});
|
||
}
|