import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { isDeepStrictEqual } from 'node:util'; import { settings } from './config.js'; import { applyGatewayPreference, buildGatewayPresence, createGatewayAutoState, nextGatewayAutoState, probeGatewayPresence, readHostNetworkState, sameGatewayRoute, } from './gatewayPresence.js'; import { setGatewayInterception } from './gatewayRouting.js'; import { tcpPing } from './ping.js'; import { buildSharedProxyInfo } from './sharedProxy.js'; import { buildGatewayConfig, removeSingboxConfig, writeSingboxConfig, } from './singbox.js'; import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000; fs.mkdirSync(settings.dataDir, { recursive: true }); let singboxProcess = null; let singboxStartedAt = null; let subscriptionRefreshPromise = null; let subscriptionRefreshTimer = null; let gatewayDiscoveryPromise = null; let gatewayDiscoveryTimer = null; let gatewayAutoState = createGatewayAutoState(); let controlOperation = Promise.resolve(); function readJson(filePath, fallback) { try { return fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : fallback; } catch { return fallback; } } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); } function serializeControl(operation) { const result = controlOperation.then(operation, operation); controlOperation = result.catch(() => {}); return result; } function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(payload)); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; let tooLarge = false; req.on('data', (chunk) => { if (tooLarge) return; size += chunk.length; if (size > MAX_BODY_BYTES) { tooLarge = true; const error = new Error('Тело запроса слишком большое'); error.statusCode = 413; reject(error); return; } chunks.push(chunk); }); req.on('end', () => { if (tooLarge) return; if (!chunks.length) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { const error = new Error('Невалидный JSON в теле запроса'); error.statusCode = 400; reject(error); } }); req.on('error', reject); }); } function subscriptionHost(url) { try { return `${new URL(url).host}/…`; } catch { return ''; } } function buildActiveConfig(subscriptionConfig, selectedTag) { return buildGatewayConfig(subscriptionConfig, selectedTag, { clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct', }); } function checkSingboxConfig() { const result = spawnSync('sing-box', ['check', '-c', settings.configPath], { encoding: 'utf8', }); if (result.status !== 0) { throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim()); } } function stopSingbox() { return new Promise((resolve) => { if (settings.appMode === 'gateway') { setGatewayInterception(false, settings.tproxyChain); } if (!singboxProcess) { singboxStartedAt = null; return resolve(); } const current = singboxProcess; singboxProcess = null; singboxStartedAt = null; const timeout = setTimeout(() => { current.kill('SIGKILL'); resolve(); }, 4000); current.once('exit', () => { clearTimeout(timeout); resolve(); }); current.kill('SIGTERM'); }); } async function startSingbox() { if (!fs.existsSync(settings.configPath)) { if (settings.appMode === 'gateway') { setGatewayInterception(false, settings.tproxyChain); } return false; } checkSingboxConfig(); await stopSingbox(); const child = spawn('sing-box', ['run', '-c', settings.configPath], { stdio: ['ignore', 'inherit', 'inherit'], }); singboxProcess = child; singboxStartedAt = new Date().toISOString(); try { if (settings.appMode === 'gateway') { setGatewayInterception(true, settings.tproxyChain); } } catch (error) { child.kill('SIGTERM'); singboxProcess = null; singboxStartedAt = null; throw error; } child.once('exit', () => { if (singboxProcess === child) { singboxProcess = null; singboxStartedAt = null; if (settings.appMode === 'gateway') { setGatewayInterception(false, settings.tproxyChain); } } }); return true; } function publicState() { const state = readJson(settings.statePath, {}); const gatewayAutoEnabled = state.gatewayAutoEnabled !== false; return { mode: settings.appMode, port: settings.port, proxyPort: settings.proxyPort, configExists: fs.existsSync(settings.configPath), singboxRunning: Boolean(singboxProcess), singboxStartedAt, subscriptionHost: subscriptionHost(state.subscriptionUrl), hasSubscription: Boolean(state.subscriptionUrl), selectedTag: state.selectedTag || '', userInfo: state.userInfo || {}, fetchedAt: state.fetchedAt || null, gatewayAuto: settings.appMode === 'client' ? { mode: gatewayAutoState.mode, enabled: gatewayAutoEnabled, available: Boolean(gatewayAutoState.gatewayId), address: gatewayAutoState.gateway?.gateway || '', interface: gatewayAutoState.gateway?.interface || '', failures: gatewayAutoState.failures, lastError: gatewayAutoState.lastError, } : null, servers: (state.servers || []).map((server) => ({ ...server, tag: String(server.tag || '').trim(), })), }; } function writeCurrentConfig() { const state = readJson(settings.statePath, {}); const cached = readJson(settings.subscriptionCachePath, null); if (!state.selectedTag || !cached?.config) return false; writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag)); return true; } async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) { const previousState = gatewayAutoState; const modeChanged = previousState.mode !== nextState.mode; gatewayAutoState = nextState; if (!modeChanged) return; const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; const wasRunning = Boolean(singboxProcess); try { const configured = writeCurrentConfig(); if (reconfigure && configured && wasRunning) await startSingbox(); } catch (error) { gatewayAutoState = previousState; if (previousConfig === null) removeSingboxConfig(); else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); throw error; } const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : ''; console.log(`[control] client route: ${nextState.mode}${route}`); } function refreshGatewayAutoMode({ reconfigure = true } = {}) { if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState); if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise; gatewayDiscoveryPromise = serializeControl(async () => { const state = readJson(settings.statePath, {}); const network = state.subscriptionUrl ? readHostNetworkState(settings.hostNetworkStatePath) : null; if (!network) { const nextState = nextGatewayAutoState(gatewayAutoState, { network: null }); if (state.subscriptionUrl) { nextState.lastError = 'macOS default gateway недоступен или устарел'; } await applyGatewayAutoState( nextState, { reconfigure }, ); return gatewayAutoState; } if ( gatewayAutoState.mode === 'gateway-direct' && !sameGatewayRoute(gatewayAutoState.gateway, network) ) { await applyGatewayAutoState( nextGatewayAutoState(gatewayAutoState, { network }), { reconfigure }, ); } try { const verifiedGateway = await probeGatewayPresence({ gateway: network.gateway, port: settings.gatewayPresencePort, subscriptionUrl: state.subscriptionUrl, }); const latestState = readJson(settings.statePath, {}); const latestNetwork = latestState.subscriptionUrl ? readHostNetworkState(settings.hostNetworkStatePath) : null; if ( latestState.subscriptionUrl !== state.subscriptionUrl || !sameGatewayRoute(network, latestNetwork) ) { await applyGatewayAutoState(createGatewayAutoState(), { reconfigure }); return gatewayAutoState; } await applyGatewayAutoState( applyGatewayPreference( nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }), latestState.gatewayAutoEnabled !== false, ), { reconfigure }, ); } catch (error) { const reason = error?.message || 'Gateway presence check failed'; const latestState = readJson(settings.statePath, {}); const latestNetwork = latestState.subscriptionUrl ? readHostNetworkState(settings.hostNetworkStatePath) : null; if ( latestState.subscriptionUrl !== state.subscriptionUrl || !sameGatewayRoute(network, latestNetwork) ) { await applyGatewayAutoState(createGatewayAutoState(), { reconfigure }); return gatewayAutoState; } if (gatewayAutoState.lastError !== reason) { console.warn(`[control] Gateway не используется: ${reason}`); } await applyGatewayAutoState( applyGatewayPreference( nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }), latestState.gatewayAutoEnabled !== false, ), { reconfigure }, ); } return gatewayAutoState; }).finally(() => { gatewayDiscoveryPromise = null; }); return gatewayDiscoveryPromise; } async function applySelectedServer(selectedTag) { const cached = readJson(settings.subscriptionCachePath, null); if (!cached?.config) throw new Error('Сначала загрузите подписку'); const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; writeSingboxConfig(buildActiveConfig(cached.config, selectedTag)); try { await startSingbox(); } catch (error) { if (previousConfig === null) removeSingboxConfig(); else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); throw error; } writeJson(settings.statePath, { ...readJson(settings.statePath, {}), selectedTag, appliedAt: new Date().toISOString(), }); } function refreshSavedSubscription() { if (subscriptionRefreshPromise) return subscriptionRefreshPromise; subscriptionRefreshPromise = (async () => { const initialState = readJson(settings.statePath, {}); if (!initialState.subscriptionUrl) { const error = new Error('Подписка не настроена'); error.statusCode = 400; throw error; } const subscriptionUrl = initialState.subscriptionUrl; const parsed = await fetchSubscription(subscriptionUrl); return serializeControl(async () => { const currentState = readJson(settings.statePath, {}); if (currentState.subscriptionUrl !== subscriptionUrl) { throw new Error('Подписка была изменена во время обновления'); } const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); const previousCache = readJson(settings.subscriptionCachePath, null); const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && ( !previousCache?.config || !isDeepStrictEqual( buildActiveConfig(previousCache.config, currentState.selectedTag), buildActiveConfig(parsed.config, selectedTag), ) ); writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); try { if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag); else if (selectedTag) { if (!singboxProcess) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag)); } else { removeSingboxConfig(); } } catch (error) { if (previousCache) writeJson(settings.subscriptionCachePath, previousCache); else fs.rmSync(settings.subscriptionCachePath, { force: true }); if (previousConfig === null) removeSingboxConfig(); else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); throw error; } writeJson(settings.statePath, { ...readJson(settings.statePath, {}), subscriptionUrl, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, selectedTag, }); return { success: true, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, selectedTag, }; }); })().finally(() => { subscriptionRefreshPromise = null; }); return subscriptionRefreshPromise; } async function handleApi(req, res) { if (req.method === 'GET' && req.url === '/api/state') { return sendJson(res, 200, publicState()); } if (req.method === 'GET' && req.url === '/api/shared-proxy') { return sendJson(res, 200, buildSharedProxyInfo({ appMode: settings.appMode, proxyPort: settings.proxyPort, running: Boolean(singboxProcess), hostHeader: req.headers.host, sharedProxyHost: settings.sharedProxyHost, })); } const requestUrl = new URL(req.url, `http://localhost:${settings.port}`); if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') { const state = readJson(settings.statePath, {}); return sendJson(res, 200, buildGatewayPresence({ appMode: settings.appMode, subscriptionUrl: state.subscriptionUrl, gatewayId: getHwid(), nonce: requestUrl.searchParams.get('nonce'), })); } if (req.method === 'POST' && req.url === '/api/servers/ping-all') { const state = readJson(settings.statePath, {}); const results = await Promise.all((state.servers || []).map(async (server) => ({ tag: String(server.tag || '').trim(), ...await tcpPing(server.server, server.server_port), checkedAt: new Date().toISOString(), }))); return sendJson(res, 200, { success: true, results }); } if (req.method === 'POST' && req.url === '/api/subscription/fetch') { const { url = '' } = await readBody(req); const normalizedUrl = String(url).trim(); const parsed = await fetchSubscription(normalizedUrl); await serializeControl(async () => { writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed }); const previousState = readJson(settings.statePath, {}); writeJson(settings.statePath, { subscriptionUrl: normalizedUrl, gatewayAutoEnabled: previousState.gatewayAutoEnabled !== false, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, }); await stopSingbox(); removeSingboxConfig(); gatewayAutoState = createGatewayAutoState(); }); return sendJson(res, 200, { success: true, ...parsed }); } if (req.method === 'POST' && req.url === '/api/subscription/validate') { const { url = '' } = await readBody(req); const parsed = await fetchSubscription(String(url).trim()); return sendJson(res, 200, { success: true, servers: parsed.servers.length }); } if (req.method === 'POST' && req.url === '/api/subscription/refresh') { return sendJson(res, 200, await refreshSavedSubscription()); } if (req.method === 'POST' && req.url === '/api/gateway-auto') { if (settings.appMode !== 'client') { return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' }); } const { enabled } = await readBody(req); if (typeof enabled !== 'boolean') { return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' }); } await serializeControl(async () => { writeJson(settings.statePath, { ...readJson(settings.statePath, {}), gatewayAutoEnabled: enabled, }); await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled)); }); return sendJson(res, 200, { success: true, gatewayAuto: publicState().gatewayAuto }); } if (req.method === 'DELETE' && req.url === '/api/subscription') { await serializeControl(async () => { await stopSingbox(); removeSingboxConfig(); fs.rmSync(settings.subscriptionCachePath, { force: true }); writeJson(settings.statePath, {}); gatewayAutoState = createGatewayAutoState(); }); return sendJson(res, 200, { success: true }); } if (req.method === 'POST' && req.url === '/api/apply') { const { selectedTag = '' } = await readBody(req); const tag = String(selectedTag).trim(); if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' }); await serializeControl(() => applySelectedServer(tag)); return sendJson(res, 200, { success: true, selectedTag: tag }); } if (req.method === 'POST' && req.url === '/api/singbox/stop') { await serializeControl(() => stopSingbox()); return sendJson(res, 200, { success: true, singboxRunning: false }); } if (req.method === 'POST' && req.url === '/api/singbox/restart') { await serializeControl(async () => { if (!fs.existsSync(settings.configPath)) { const error = new Error('Сначала выберите сервер'); error.statusCode = 400; throw error; } await startSingbox(); }); return sendJson(res, 200, { success: true, singboxRunning: true }); } return sendJson(res, 404, { success: false, error: 'Не найдено' }); } const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8', }; function serveStatic(req, res) { const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname; const requested = pathname === '/' ? 'index.html' : pathname.slice(1); const filePath = path.resolve(settings.distDir, requested); const relative = path.relative(path.resolve(settings.distDir), filePath); if (relative.startsWith('..') || path.isAbsolute(relative)) { res.writeHead(403); return res.end('Forbidden'); } const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() ? filePath : path.join(settings.distDir, 'index.html'); res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' }); fs.createReadStream(finalPath).pipe(res); } const server = http.createServer(async (req, res) => { try { return req.url?.startsWith('/api/') ? await handleApi(req, res) : serveStatic(req, res); } catch (error) { console.error('[control] request failed', error); return sendJson(res, error.statusCode || 500, { success: false, error: error.message || String(error), }); } }); async function shutdown() { clearInterval(subscriptionRefreshTimer); clearInterval(gatewayDiscoveryTimer); await serializeControl(() => stopSingbox()); process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); await refreshGatewayAutoMode({ reconfigure: false }) .catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`)); if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) { writeCurrentConfig(); } await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`)); server.listen(settings.port, '0.0.0.0', () => { console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); }); subscriptionRefreshTimer = setInterval(() => { if (!readJson(settings.statePath, {}).subscriptionUrl) return; refreshSavedSubscription() .catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`)); }, SUBSCRIPTION_REFRESH_INTERVAL_MS); subscriptionRefreshTimer.unref(); gatewayDiscoveryTimer = setInterval(() => { refreshGatewayAutoMode() .catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`)); }, GATEWAY_DISCOVERY_INTERVAL_MS); gatewayDiscoveryTimer.unref();