Files
harbor-net/src/server/index.js
Dmitriy Petrov 8c19f2cba9
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s
Remove initial server health check and bump Harbor versions
2026-07-13 13:36:56 +03:00

806 lines
27 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { createDataplaneClient } from './dataplaneClient.js';
import { settings } from './config.js';
import {
applyGatewayPreference,
buildGatewayPresence,
createGatewayAutoState,
nextGatewayAutoState,
probeGatewayPresence,
readHostNetworkState,
sameGatewayRoute,
} from './gatewayPresence.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { tcpPing } from './ping.js';
import { checkServerHealth } from './serverHealth.js';
import { buildSharedProxyInfo } from './sharedProxy.js';
import {
buildGatewayConfig,
removeSingboxConfig,
restoreSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
import {
fetchSubscription,
getHwid,
normalizeSubscriptionConfig,
selectRefreshedServer,
} from './subscription.js';
import {
createStateSnapshot,
normalizeStoredState,
withStateV0Compatibility,
} from '../shared/contracts/state.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
const TERMINAL_SUBSCRIPTION_CODES = new Set([
'SUBSCRIPTION_EXPIRED',
'SUBSCRIPTION_DISABLED',
'SUBSCRIPTION_REJECTED',
]);
fs.mkdirSync(settings.dataDir, { recursive: true });
const stateStore = createStateStore(settings.statePath);
const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath,
defaultValue: null,
});
let cacheRecoveryLogged = false;
function readSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached?.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
: cached;
}
const initialStoredState = stateStore.read();
if (stateStore.migration) {
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
}
if (stateStore.recovery) {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
}
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
const versionInfo = buildVersionInfo(settings.appMode);
const singboxRuntime = remoteDataplane
? createDataplaneClient(settings.dataplaneSocket)
: createSingboxRuntime({
configPath: settings.configPath,
gateway: settings.appMode === 'gateway',
tproxyChain: settings.tproxyChain,
});
let subscriptionRefreshPromise = null;
let subscriptionRefreshTimer = null;
let gatewayDiscoveryPromise = null;
let gatewayDiscoveryTimer = null;
let gatewayAutoState = createGatewayAutoState();
let controlOperation = Promise.resolve();
let operationState = stateStore.recovery ? {
kind: 'storage-recovery',
status: 'failed',
startedAt: stateStore.recovery.recoveredAt,
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
} : { kind: null, status: 'idle', startedAt: null, error: null };
let revision = normalizeStoredState(initialStoredState).revision;
function updateStoredState(update) {
return stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return next;
});
}
async function withOperation(kind, operation) {
operationState = {
kind,
status: 'running',
startedAt: new Date().toISOString(),
error: null,
};
updateStoredState((state) => state);
try {
const result = await operation();
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
updateStoredState((state) => state);
return result;
} catch (error) {
const harborError = normalizeHarborError(error);
operationState = {
...operationState,
status: 'failed',
error: harborError.message,
};
updateStoredState((state) => state);
throw error;
}
}
function serializeControl(operation) {
const result = controlOperation.then(operation, operation);
// The caller observes result; this settled tail only keeps the next operation runnable.
controlOperation = result.then(() => undefined, () => undefined);
return result;
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(payload));
}
function redactLogDetails(value) {
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
}
function sendError(res, error) {
const harborError = normalizeHarborError(error);
const correlationId = crypto.randomUUID();
const technical = harborError.cause?.message || harborError.details || error;
console.error(
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
);
return sendJson(res, harborError.status, {
success: false,
error: {
code: harborError.code,
message: harborError.message,
retryable: harborError.retryable,
correlationId,
...(harborError.details ? { details: harborError.details } : {}),
},
});
}
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;
reject(new HarborError('REQUEST_INVALID'));
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 (cause) {
reject(new HarborError('REQUEST_INVALID', { cause }));
}
});
req.on('error', reject);
});
}
function subscriptionHost(url) {
try {
return `${new URL(url).host}/…`;
} catch {
return '';
}
}
function buildActiveConfig(
subscriptionConfig,
selectedServerId,
routeRules = stateStore.read().routeRules,
) {
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
routeRules,
});
}
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
function resetSavedSubscription({ stopRuntime = true } = {}) {
return serializeControl(async () => {
if (stopRuntime) await stopSingbox();
removeSingboxConfig();
subscriptionCacheStore.remove();
updateStoredState((state) => ({ routeRules: state.routeRules }));
gatewayAutoState = createGatewayAutoState();
});
}
async function publicState() {
const runtime = await singboxRuntime.refresh();
const state = normalizeStoredState(stateStore.read());
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
const configExists = fs.existsSync(settings.configPath);
const snapshot = createStateSnapshot({
storedState: state,
runtime,
gatewayAuto: gatewayAutoState,
appMode: settings.appMode,
configExists,
subscriptionHost: subscriptionHost(state.subscriptionUrl),
operation: operationState,
});
return withStateV0Compatibility(snapshot, {
storedState: { ...state, gatewayAutoEnabled },
gatewayAuto: gatewayAutoState,
port: settings.port,
proxyPort: settings.proxyPort,
configExists,
});
}
function writeCurrentConfig() {
const state = stateStore.read();
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
return true;
}
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
const previousState = gatewayAutoState;
const stateChanged = !isDeepStrictEqual(previousState, nextState);
const modeChanged = previousState.mode !== nextState.mode;
gatewayAutoState = nextState;
if (!modeChanged) {
if (stateChanged) updateStoredState((state) => state);
return;
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = singboxRuntime.running;
try {
const configured = writeCurrentConfig();
if (reconfigure && configured && wasRunning) await startSingbox();
} catch (error) {
gatewayAutoState = previousState;
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
throw error;
}
if (stateChanged) updateStoredState((state) => state);
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 = stateStore.read();
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 = stateStore.read();
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 = stateStore.read();
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(selectedServerId, { persist = true } = {}) {
const cached = readSubscriptionCache();
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
if (persist) {
updateStoredState((state) => ({
...state,
selectedServerId,
connectionDesired: 'running',
}));
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
writeSingboxConfig(nextConfig);
try {
await startSingbox();
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
throw error;
}
updateStoredState((state) => ({
...state,
...(persist ? {
appliedServerId: selectedServerId,
appliedAt: new Date().toISOString(),
} : {}),
appliedRouteRules: state.routeRules,
}));
}
async function applyRouteRules(routeRules) {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) {
updateStoredState((current) => ({
...current,
routeRules,
routeRulesRevision: current.routeRulesRevision + 1,
}));
return;
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
if (wasRunning) await startSingbox();
updateStoredState((current) => ({
...current,
routeRules,
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
routeRulesRevision: current.routeRulesRevision + 1,
}));
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
if (wasRunning) {
try {
await startSingbox();
} catch (rollbackError) {
throw new HarborError('PROCESS_START_FAILED', {
cause: new AggregateError([error, rollbackError], 'Route rules rollback failed'),
});
}
}
throw error;
}
}
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
return serializeControl(async () => {
const previousState = normalizeStoredState(stateStore.read());
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
throw new HarborError('STATE_CONFLICT');
}
const selectedServerId = resetSelection
? ''
: selectRefreshedServer(
previousState.selectedServerId,
previousState.servers,
parsed.servers,
);
const candidateConfig = selectedServerId
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
: null;
const previousCache = readSubscriptionCache();
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const previousGatewayAutoState = gatewayAutoState;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
if (candidateConfig) writeSingboxConfig(candidateConfig);
else removeSingboxConfig();
subscriptionCacheStore.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
updateStoredState((state) => ({
...(resetSelection ? {
routeRules: state.routeRules,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
connectionDesired: 'stopped',
} : state),
subscriptionUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
if (resetSelection) gatewayAutoState = createGatewayAutoState();
} catch (error) {
gatewayAutoState = previousGatewayAutoState;
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
if (wasRunning) {
try {
await startSingbox();
} catch (rollbackError) {
throw new HarborError('PROCESS_START_FAILED', {
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
});
}
}
throw error;
}
return {
success: true,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
};
});
}
async function importSubscription(subscriptionUrl) {
const parsed = await fetchSubscription(subscriptionUrl);
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
}
function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => {
try {
const subscriptionUrl = stateStore.read().subscriptionUrl;
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
const parsed = await fetchSubscription(subscriptionUrl);
return await commitSubscription(subscriptionUrl, parsed);
} catch (error) {
if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
await resetSavedSubscription();
}
throw error;
}
})().finally(() => {
subscriptionRefreshPromise = null;
});
return subscriptionRefreshPromise;
}
async function sendState(res, extra = {}) {
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
}
async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') {
return sendJson(res, 200, await publicState());
}
if (req.method === 'GET' && req.url === '/api/version') {
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
const runtime = await singboxRuntime.refresh();
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
}
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
return sendJson(res, 200, buildSharedProxyInfo({
appMode: settings.appMode,
proxyPort: settings.proxyPort,
running: (await singboxRuntime.refresh()).running,
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 = stateStore.read();
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 = stateStore.read();
const { serverIds = [] } = await readBody(req);
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
const servers = requestedIds.size
? (state.servers || []).filter((server) => requestedIds.has(server.id))
: state.servers || [];
const results = await checkServerHealth(servers, tcpPing);
return sendState(res, { results });
}
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim();
const parsed = await withOperation('subscription-import', async () => {
return importSubscription(normalizedUrl);
});
return sendState(res, parsed);
}
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
const { url = '' } = await readBody(req);
const parsed = await fetchSubscription(String(url).trim());
return sendState(res, { servers: parsed.servers.length });
}
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
const { success, ...result } = await withOperation(
'subscription-refresh',
() => refreshSavedSubscription(),
);
return sendState(res, result);
}
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
if (settings.appMode !== 'client') {
throw new HarborError('REQUEST_INVALID');
}
const { enabled } = await readBody(req);
if (typeof enabled !== 'boolean') {
throw new HarborError('REQUEST_INVALID');
}
await withOperation('gateway-auto', () => serializeControl(async () => {
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
updateStoredState((state) => ({
...state,
gatewayAutoEnabled: enabled,
}));
}));
const state = await publicState();
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
}
if (req.method === 'PUT' && req.url === '/api/route-rules') {
const { rules, expectedRulesRevision, expectedRevision } = await readBody(req);
let routeRules;
try {
routeRules = normalizeRouteRules(rules, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
const rulesRevision = expectedRulesRevision ?? expectedRevision;
if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) {
throw new HarborError('REQUEST_INVALID');
}
await serializeControl(async () => {
const current = normalizeStoredState(stateStore.read());
const currentRevision = expectedRulesRevision == null
? current.revision
: current.routeRulesRevision;
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
await withOperation('route-rules', () => applyRouteRules(routeRules));
});
return sendState(res);
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
await withOperation('subscription-forget', () => resetSavedSubscription());
return sendState(res);
}
if (req.method === 'POST' && req.url === '/api/apply') {
const { serverId = '', selectedTag = '' } = await readBody(req);
const state = normalizeStoredState(stateStore.read());
const id = String(serverId).trim() || (() => {
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
return matches.length === 1 ? matches[0].id : '';
})();
if (!id || !state.servers.some((server) => server.id === id)) {
throw new HarborError('SERVER_NOT_FOUND');
}
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
return sendState(res, {
serverId: id,
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
});
}
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
await withOperation('stop', () => serializeControl(async () => {
await stopSingbox();
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
}));
return sendState(res, { singboxRunning: false });
}
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
await withOperation('start', () => serializeControl(async () => {
if (!fs.existsSync(settings.configPath)) {
throw new HarborError('CONFIG_INVALID');
}
await singboxRuntime.restart();
updateStoredState((state) => ({
...state,
appliedServerId: state.selectedServerId,
connectionDesired: 'running',
appliedRouteRules: state.routeRules,
}));
}));
return sendState(res, { singboxRunning: true });
}
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
}
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) {
return sendError(res, error);
}
});
async function shutdown() {
clearInterval(subscriptionRefreshTimer);
clearInterval(gatewayDiscoveryTimer);
await serializeControl(() => singboxRuntime.shutdown());
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)) {
try {
writeCurrentConfig();
} catch (error) {
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
await resetSavedSubscription({ stopRuntime: false });
}
}
await startSingbox()
.then(() => {
if (fs.existsSync(settings.configPath)) {
updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules }));
}
})
.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 (!stateStore.read().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();