import crypto from 'node:crypto'; import fs from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; import { setGatewayInterception } from './gatewayRouting.js'; import { HarborError } from '../shared/errors.js'; export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) { let child = null; let configHash = ''; let startedAt = null; const state = () => ({ running: Boolean(child), startedAt }); async function stop() { if (gateway) setGatewayInterception(false, tproxyChain); if (!child) { configHash = ''; startedAt = null; return state(); } const current = child; child = null; configHash = ''; startedAt = null; await new Promise((resolve) => { const timeout = setTimeout(() => { current.kill('SIGKILL'); resolve(); }, 4000); current.once('exit', () => { clearTimeout(timeout); resolve(); }); current.kill('SIGTERM'); }); return state(); } async function apply({ force = false } = {}) { if (!fs.existsSync(configPath)) { await stop(); return state(); } const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); if (check.status !== 0) { throw new HarborError('CONFIG_INVALID', { cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), }); } const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); if (!force && child && nextHash === configHash) return state(); await stop(); let current; try { current = spawn('sing-box', ['run', '-c', configPath], { stdio: ['ignore', 'inherit', 'inherit'], }); await new Promise((resolve, reject) => { current.once('spawn', resolve); current.once('error', reject); }); } catch (cause) { throw new HarborError('PROCESS_START_FAILED', { cause }); } child = current; configHash = nextHash; startedAt = new Date().toISOString(); try { if (gateway) setGatewayInterception(true, tproxyChain); } catch (error) { current.kill('SIGTERM'); child = null; configHash = ''; startedAt = null; throw new HarborError('PROCESS_START_FAILED', { cause: error }); } current.once('exit', () => { if (child !== current) return; child = null; configHash = ''; startedAt = null; if (gateway) setGatewayInterception(false, tproxyChain); }); return state(); } return { get running() { return Boolean(child); }, get startedAt() { return startedAt; }, refresh: async () => state(), apply, restart: () => apply({ force: true }), stop, shutdown: stop, }; }