import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { createDataplaneClient } from './dataplaneClient.js'; import { readNeighborSnapshot } from './adapters/neighbors.js'; import { settings } from './config.js'; import { applyGatewayPreference, createGatewayAutoState, nextGatewayAutoState, probeGatewayPresence, readHostNetworkState, sameGatewayRoute, } from './gatewayPresence.js'; import { createSingboxRuntime } from './singboxRuntime.js'; import { tcpPing } from './ping.js'; import { buildDualChannelGatewayConfig, buildGatewayConfig, dualChannelConfigMatchesApplied, fingerprintSelectedOutbound, removeSingboxConfig, restoreSingboxConfig, writeSingboxConfig, } from './singbox.js'; import { fetchSubscription, getHwid, normalizeSubscriptionConfig, selectRefreshedServer, } from './subscription.js'; import { desiredProfile, normalizeStoredState, type OperationState, type RouteRule, type StoredState, } from '../shared/contracts/state.js'; import { serverIdentityKey } from '../shared/serverIdentity.js'; import { HarborError, normalizeHarborError } from '../shared/errors.js'; import { atomicWriteFile, createJsonStore, createStateStore, } from './services/stateStore.js'; import { createDevicePolicyService } from './services/devicePolicyService.js'; import { createDeviceInventoryService, createVendorLookup, DEVICE_INVENTORY_SCHEMA_VERSION, migrateDeviceInventoryState, type InventoryState, } from './services/deviceInventoryService.js'; import { buildVersionInfo } from './version.js'; import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; import { createStateService } from './features/state/stateService.js'; import { createStateRoute } from './http/routes/stateRoute.js'; import { sendError } from './http/response.js'; import { createSubscriptionService, createValidateSubscription, } from './features/subscription/index.js'; import { createSubscriptionValidationRoute } from './http/routes/subscriptionValidationRoute.js'; import { createSubscriptionMutationRoute } from './http/routes/subscriptionMutationRoute.js'; import { createServerHealthService } from './features/servers/index.js'; import { createServerHealthRoute } from './http/routes/serverHealthRoute.js'; import { captureRuntimeCommand, createConnectionService, } from './features/connection/index.js'; import { createServerApplyRoute } from './http/routes/serverApplyRoute.js'; import { createConnectionRuntimeRoute } from './http/routes/connectionRuntimeRoute.js'; import { createGatewayAutoService, createRouteRulesService, } from './features/routing/index.js'; import { createRouteRulesRoute } from './http/routes/routeRulesRoute.js'; import { createGatewayAutoRoute } from './http/routes/gatewayAutoRoute.js'; import { createDeviceInventoryRoute } from './http/routes/deviceInventoryRoute.js'; import { createPrometheusMetricsRoute } from './http/routes/prometheusMetricsRoute.js'; import { createConnectivityDiagnosticsUseCase } from './features/diagnostics/index.js'; import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDiagnosticsRoute.js'; import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js'; import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js'; import { createVersionRoute } from './http/routes/versionRoute.js'; import { createSingboxSelectorService } from './services/singboxSelectorService.js'; import { createFailoverService } from './features/failover/failoverService.js'; import { createFailoverRoute } from './http/routes/failoverRoute.js'; import { createActivityJournalService } from './services/activityJournalService.js'; import { createActivityJournalRoute } from './http/routes/activityJournalRoute.js'; import { createDomainTrafficService, readSingboxConnections } from './services/domainTrafficService.js'; import type { ActivityJournalEventInput } from '../shared/activityJournal.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000; const DEVICE_DISCOVERY_INTERVAL_MS = 15_000; function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } fs.mkdirSync(settings.dataDir, { recursive: true }); const stateFileExisted = fs.existsSync(settings.statePath); const legacyStateBytes = stateFileExisted ? fs.readFileSync(settings.statePath, 'utf8') : null; let legacyStateRecord: Record = {}; try { legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes)); } catch {} const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion) ? Number(legacyStateRecord.schemaVersion) : 0; const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath) ? fs.readFileSync(settings.subscriptionCachePath, 'utf8') : null; const subscriptionCacheStore = createJsonStore({ filePath: settings.subscriptionCachePath, defaultValue: null, }); const rawLegacySubscriptionCache = subscriptionCacheStore.read(); const legacyCacheRecord = record(rawLegacySubscriptionCache); const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim(); const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim(); const legacyCacheOwnerMismatch = legacyStateVersion < 5 && Boolean(legacyCacheRecord.config) && (legacyStateSubscriptionUrl ? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl : !legacyCacheSubscriptionUrl); let legacySubscriptionCache = rawLegacySubscriptionCache; let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery); if (legacyCacheOwnerMismatch) { legacySubscriptionCache = null; } else if (legacyCacheRecord.config) { try { legacySubscriptionCache = { ...legacyCacheRecord, ...normalizeSubscriptionConfig(legacyCacheRecord.config), }; } catch { legacySubscriptionCache = null; legacySubscriptionCacheRejected = true; } } const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache }); const activityJournal = createActivityJournalService({ filePath: settings.activityJournalPath }); const appendJournal = (event: ActivityJournalEventInput) => { try { activityJournal.append(event); } catch (error) { console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`); } }; const deviceStore = createJsonStore({ filePath: settings.deviceStatePath, defaultValue: migrateDeviceInventoryState({}), migrate: migrateDeviceInventoryState, initializeMissing: true, backupWhen: () => true, }); deviceStore.read(); if (deviceStore.migration) { console.log(`[storage] devices migrated to v${DEVICE_INVENTORY_SCHEMA_VERSION}; backup: ${deviceStore.migration.backupPath}`); } if (deviceStore.recovery) { console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`); } let 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}`); } if (subscriptionCacheStore.recovery) { console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`); } const rejectedLegacyMigration = legacySubscriptionCacheRejected && ( !stateFileExisted || Boolean(stateStore.recovery) || Boolean(stateStore.migration && stateStore.migration.fromVersion < 5) ); const mismatchedLegacyMigration = legacyCacheOwnerMismatch && ( !stateFileExisted || Boolean(stateStore.recovery) || Boolean(stateStore.migration && stateStore.migration.fromVersion < 5) ); if (rejectedLegacyMigration) { initialStoredState = stateStore.update((state) => ({ ...state, profiles: [], desiredProfileId: '', appliedProfileId: '', appliedServerId: '', appliedServerSnapshot: null, connectionDesired: 'stopped', })); removeSingboxConfig(); } else if (mismatchedLegacyMigration) { initialStoredState = stateStore.update((state) => ({ ...state, appliedProfileId: '', appliedServerId: '', appliedServerSnapshot: null, connectionDesired: 'stopped', })); removeSingboxConfig(); } if ( legacySubscriptionCacheBytes !== null && ( Boolean(subscriptionCacheStore.recovery) || ( Boolean(legacyCacheRecord.config) && ( legacySubscriptionCacheRejected || legacyCacheOwnerMismatch || normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig) ) ) ) ) { const backupPath = subscriptionCacheStore.recovery?.backupPath || `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`; if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes); subscriptionCacheStore.remove(); console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`); } function readProfileConfig(profileId = '') { const state = normalizeStoredState(stateStore.read()); const profile = profileId ? state.profiles.find((candidate) => candidate.id === profileId) : desiredProfile(state); return profile?.subscriptionConfig || null; } const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET); const versionInfo = buildVersionInfo(settings.appMode); const remoteRuntime = remoteDataplane ? createDataplaneClient(settings.dataplaneSocket) : null; const localRuntime = remoteDataplane ? null : createSingboxRuntime({ configPath: settings.configPath, gateway: settings.appMode === 'gateway', tproxyChain: settings.tproxyChain, }); function selectRuntime() { if (remoteRuntime) return remoteRuntime; if (localRuntime) return localRuntime; throw new Error('Harbor runtime is not configured'); } const singboxRuntime = selectRuntime(); function requireRemoteRuntime() { if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured'); return remoteRuntime; } function requireLocalDevicePolicy() { if (!localDevicePolicy) throw new Error('Harbor local device policy is not configured'); return localDevicePolicy; } const localDevicePolicy = settings.appMode === 'gateway' && !remoteDataplane ? createDevicePolicyService({ chain: settings.devicePolicyChain, tproxyPort: settings.tproxyPort, tproxyMark: settings.tproxyMark, }) : null; const deviceInventory = settings.appMode === 'gateway' ? createDeviceInventoryService({ store: deviceStore, observe: remoteDataplane ? () => requireRemoteRuntime().observeDevices() : () => readNeighborSnapshot(), observeTraffic: remoteDataplane ? () => requireRemoteRuntime().observeTraffic() : null, observeDomainTraffic: remoteDataplane ? () => requireRemoteRuntime().observeDomainTraffic() : null, observePolicy: remoteDataplane ? () => requireRemoteRuntime().observeDevicePolicy() : () => requireLocalDevicePolicy().snapshot(), applyPolicies: remoteDataplane ? (devices) => requireRemoteRuntime().applyDevicePolicies(devices) : (devices) => requireLocalDevicePolicy().apply(devices), vendor: createVendorLookup(), }) : null; const localConnectivityDiagnostics = !remoteDataplane ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) : null; const localFailoverDiagnostics = !remoteDataplane ? { primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }), reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }), } : null; const localSelector = !remoteDataplane && settings.appMode === 'gateway' ? createSingboxSelectorService({ port: settings.singboxApiPort }) : null; const localFailoverTraffic = !remoteDataplane && settings.appMode === 'gateway' ? createDomainTrafficService({ observe: () => readSingboxConnections(settings.singboxApiPort), devices: () => record(deviceInventory?.snapshot()).devices, }) : null; let localFailoverTrafficTimer: NodeJS.Timeout | null = null; function setLocalFailoverActivityEnabled(enabled: boolean) { if (!localFailoverTraffic) throw new Error('Failover activity недоступна'); if (!enabled) { if (localFailoverTrafficTimer) clearInterval(localFailoverTrafficTimer); localFailoverTrafficTimer = null; localFailoverTraffic.disableActivity(); return; } localFailoverTraffic.enableActivity(); if (localFailoverTrafficTimer) return; const refresh = () => localFailoverTraffic.refresh() .catch((error: unknown) => console.warn(`[control] failover activity: ${errorMessage(error)}`)); void refresh(); localFailoverTrafficTimer = setInterval(refresh, 2_000); localFailoverTrafficTimer.unref(); } function requireLocalConnectivityDiagnostics() { if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured'); return localConnectivityDiagnostics; } let deviceDiscoveryTimer: NodeJS.Timeout | null = null; let controlOperation: Promise = Promise.resolve(); let operationState: 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; const gatewayAutoService = createGatewayAutoService({ appMode: settings.appMode, state: { read: () => normalizeStoredState(stateStore.read()), update: updateStoredState, }, subscription: { readConfig: (profileId) => readProfileConfig(profileId), }, config: { build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => ( buildGatewayConfig(subscriptionConfig, selectedServerId, { clientDirect: settings.appMode === 'client' && gatewayAuto.mode === 'gateway-direct', routeRules, }) ), read: () => fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null, write: writeSingboxConfig, restore: restoreSingboxConfig, remove: removeSingboxConfig, }, runtime: { isRunning: () => Boolean(singboxRuntime.running), applyCommand: () => captureRuntimeCommand( () => startSingbox(), { preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] }, ), restoreRunning: () => startSingbox(), stopCommand: () => captureRuntimeCommand(() => stopSingbox()), }, discovery: { readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath), probeGateway: ({ gateway, subscriptionUrl }) => probeGatewayPresence({ gateway, port: settings.gatewayPresencePort, subscriptionUrl, }), }, transition: { createInitial: createGatewayAutoState, applyPreference: applyGatewayPreference, next: nextGatewayAutoState, sameRoute: sameGatewayRoute, }, serialize: serializeControl, scheduler: { setInterval: (callback, intervalMs) => setInterval(callback, intervalMs), clearInterval: (timer) => clearInterval(timer), }, onRouteChange: (state) => { const route = state.gateway?.gateway ? ` (${state.gateway.gateway})` : ''; console.log(`[control] client route: ${state.mode}${route}`); }, onDiscoveryWarning: (reason) => console.warn(`[control] Gateway не используется: ${reason}`), onTimerError: (error) => console.warn(`[control] Gateway detection failed: ${errorMessage(error)}`), }); const failoverDataplane = remoteDataplane ? { checkConfig: (config: unknown) => requireRemoteRuntime().checkConfig(config), runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => ( requireRemoteRuntime().runFailoverProbe(role, services, target, timeoutMs) ), readFailoverSelector: () => requireRemoteRuntime().readFailoverSelector(), selectFailoverRole: (role: 'primary' | 'reserve') => requireRemoteRuntime().selectFailoverRole(role), setFailoverActivityEnabled: (enabled: boolean) => requireRemoteRuntime().setFailoverActivityEnabled(enabled), readFailoverActivity: (threshold: number) => requireRemoteRuntime().readFailoverActivity(threshold), } : { checkConfig: async (config: unknown) => singboxRuntime.checkConfig(config), runFailoverProbe: async (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => { if (!localFailoverDiagnostics) throw new Error('Failover diagnostics недоступна'); return localFailoverDiagnostics[role].runVpn({ services, target, timeoutMs }); }, readFailoverSelector: async () => { if (!localSelector) throw new Error('Failover selector недоступен'); return localSelector.read(); }, selectFailoverRole: async (role: 'primary' | 'reserve') => { if (!localSelector) throw new Error('Failover selector недоступен'); return localSelector.select(role); }, setFailoverActivityEnabled: async (enabled: boolean) => setLocalFailoverActivityEnabled(enabled), readFailoverActivity: async (threshold: number) => ({ activity: localFailoverTraffic?.activitySnapshot(threshold) || null, }), }; const failoverService = createFailoverService({ state: { read: () => normalizeStoredState(stateStore.read()), update: updateStoredState, }, runtime: { isRunning: async () => Boolean((await singboxRuntime.refresh()).running) }, dataplane: failoverDataplane, buildCandidate: buildFailoverCandidate, serialize: serializeControl, onWarning: (error) => console.warn(`[control] failover: ${errorMessage(error)}`), onSwitch: (from, to, reason) => console.log(`[control] failover ${from} -> ${to}: ${reason}`), onEvent: appendJournal, }); const gatewayFailover = settings.appMode === 'gateway' ? failoverService : null; const stateService = createStateService({ appMode: settings.appMode, readStoredState: () => stateStore.read(), refreshRuntime: () => singboxRuntime.refresh(), getGatewayAutoState: gatewayAutoService.read, getOperationState: () => operationState, configExists: () => fs.existsSync(settings.configPath), getFailoverSnapshot: failoverService.snapshot, }); const stateRoute = createStateRoute({ stateService, port: settings.port, proxyPort: settings.proxyPort, }); const gatewayAutoRoute = createGatewayAutoRoute({ appMode: settings.appMode, gatewayAuto: gatewayAutoService, readBody, withOperation, readStatePayload: stateRoute.readPayload, }); const failoverRoute = createFailoverRoute({ appMode: settings.appMode, failover: failoverService, readBody, withOperation, sendState: (res) => stateRoute.send(res), }); const activityJournalRoute = createActivityJournalRoute({ journal: activityJournal }); const deviceInventoryRoute = createDeviceInventoryRoute({ deviceInventory, readBody, }); const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory }); const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({ state: { read: () => normalizeStoredState(stateStore.read()), update: updateStoredState, }, runDiagnostics: async (services, target) => remoteDataplane ? requireRemoteRuntime().runConnectivityDiagnostics(services, target) : requireLocalConnectivityDiagnostics().run({ vpnAvailable: Boolean((await singboxRuntime.refresh()).running), services, target, }), }); const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({ diagnostics: connectivityDiagnostics, readBody, sendState: (res) => stateRoute.send(res), }); const gatewayPresenceRoute = createGatewayPresenceRoute({ appMode: settings.appMode, readState: () => normalizeStoredState(stateStore.read()), getHwid, }); const sharedProxyRoute = createSharedProxyRoute({ appMode: settings.appMode, proxyPort: settings.proxyPort, sharedProxyHost: settings.sharedProxyHost, refreshRuntime: () => singboxRuntime.refresh(), }); const versionRoute = createVersionRoute({ versionInfo, refreshDataplaneRuntime: remoteDataplane ? () => requireRemoteRuntime().refresh() : null, }); const subscriptionValidationRoute = createSubscriptionValidationRoute({ validateSubscription: createValidateSubscription(fetchSubscription), readBody, sendState: (res, extra) => stateRoute.send(res, extra), }); const subscriptionService = createSubscriptionService({ provider: { fetchSubscription, selectRefreshedServer }, state: { read: () => normalizeStoredState(stateStore.read()), update: updateStoredState, }, config: { build: (subscriptionConfig, selectedServerId, routeRules) => { const state = normalizeStoredState(stateStore.read()); return state.appliedFailoverPolicy ? buildFailoverCandidate({ ...state, routeRules }, 'applied').config : buildActiveConfig(subscriptionConfig, selectedServerId, routeRules); }, read: () => fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null, write: writeSingboxConfig, restore: restoreSingboxConfig, remove: removeSingboxConfig, }, runtime: { isRunning: async () => Boolean((await singboxRuntime.refresh()).running), stop: () => stopSingbox(), start: () => startSingbox(), }, gatewayAuto: { read: gatewayAutoService.read, set: gatewayAutoService.set, createInitial: gatewayAutoService.createInitial, }, serialize: serializeControl, scheduler: { setInterval: (callback, intervalMs) => setInterval(callback, intervalMs), clearInterval: (timer) => clearInterval(timer), }, onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`), onEvent: appendJournal, failover: gatewayFailover ? { reconcile: () => gatewayFailover.reconcile() .catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)), restoreAppliedActivation: gatewayFailover.restoreAppliedActivation, } : undefined, now: () => new Date(), }); const serverHealthRoute = createServerHealthRoute({ serverHealth: createServerHealthService({ readProfiles: () => normalizeStoredState(stateStore.read()).profiles, readDesiredProfileId: () => normalizeStoredState(stateStore.read()).desiredProfileId, ping: tcpPing, }), readBody, sendState: (res, extra) => stateRoute.send(res, extra), }); const connectionService = createConnectionService({ state: { read: () => normalizeStoredState(stateStore.read()), update: updateStoredState, }, config: { build: (subscriptionConfig, selectedServerId, routeRules) => ( buildActiveConfig(subscriptionConfig, selectedServerId, routeRules) ), read: () => fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null, write: writeSingboxConfig, restore: restoreSingboxConfig, remove: removeSingboxConfig, }, route: { isGatewayDirect: () => settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct', }, failover: gatewayFailover ? { build: buildFailoverCandidate, prepareActivation: gatewayFailover.prepareActivation, restoreAppliedActivation: gatewayFailover.restoreAppliedActivation, reconcile: () => gatewayFailover.reconcile() .catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)), } : undefined, onEvent: appendJournal, runtime: { isRunning: async () => Boolean((await singboxRuntime.refresh()).running), start: () => startSingbox(), stop: () => stopSingbox(), stopCommand: () => captureRuntimeCommand(() => stopSingbox()), restartCommand: () => captureRuntimeCommand( () => singboxRuntime.restart(), { preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] }, ), }, serialize: serializeControl, now: () => new Date(), }); const subscriptionMutationRoute = createSubscriptionMutationRoute({ subscriptionService, connection: connectionService, readBody, withOperation, sendState: (res, extra) => stateRoute.send(res, extra), }); const serverApplyRoute = createServerApplyRoute({ connection: connectionService, readBody, withOperation, sendState: (res, extra) => stateRoute.send(res, extra), }); const connectionRuntimeRoute = createConnectionRuntimeRoute({ connection: connectionService, withOperation, sendState: (res, extra) => stateRoute.send(res, extra), }); const routeRulesService = createRouteRulesService({ state: { read: () => normalizeStoredState(stateStore.read()), update: updateStoredState, }, subscription: { readConfig: (profileId) => readProfileConfig(profileId), }, config: { build: (subscriptionConfig, selectedServerId, routeRules) => { const state = normalizeStoredState(stateStore.read()); return state.appliedFailoverPolicy ? buildFailoverCandidate({ ...state, routeRules }, 'applied').config : buildActiveConfig(subscriptionConfig, selectedServerId, routeRules); }, read: () => fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null, write: writeSingboxConfig, restore: restoreSingboxConfig, remove: removeSingboxConfig, }, runtime: { isRunning: async () => Boolean((await singboxRuntime.refresh()).running), applyCommand: () => captureRuntimeCommand( () => startSingbox(), { preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] }, ), restoreRunning: () => startSingbox(), }, route: { isGatewayDirect: () => settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct', }, serialize: serializeControl, runOperation: (operation) => withOperation('route-rules', operation), afterApply: gatewayFailover ? () => gatewayFailover.reconcile() .catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)) : undefined, restoreAppliedActivation: gatewayFailover?.restoreAppliedActivation, }); const routeRulesRoute = createRouteRulesRoute({ routeRules: routeRulesService, readBody, sendState: (res) => stateRoute.send(res), }); function updateStoredState(update: (state: StoredState) => Record) { return normalizeStoredState(stateStore.update((stored) => { const current = normalizeStoredState(stored); const schemaVersion = stored.schemaVersion; const next = normalizeStoredState({ schemaVersion, ...update(current) }); revision = Math.max(revision, current.revision) + 1; next.revision = revision; return { ...next, schemaVersion }; })); } async function withOperation( kind: string, operation: (operationRevision: number) => Promise, { expectedRevision, profileId = null, serverId = null, }: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {}, ): Promise { if (operationState.status === 'running') throw new HarborError('OPERATION_IN_PROGRESS'); const currentRevision = normalizeStoredState(stateStore.read()).revision; if (expectedRevision !== undefined) { if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== currentRevision) { throw new HarborError('STATE_CONFLICT'); } } operationState = { kind, status: 'running', startedAt: new Date().toISOString(), error: null, profileId: profileId == null ? null : String(profileId), serverId: serverId == null ? null : String(serverId), }; let operationRevision: number; try { operationRevision = updateStoredState((state) => state).revision; } catch (error) { operationState = { kind: null, status: 'idle', startedAt: null, error: null, profileId: null, serverId: null, }; throw error; } try { const result = await operation(operationRevision); operationState = { kind: null, status: 'idle', startedAt: null, error: null, profileId: null, serverId: 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: () => Promise): Promise { 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 readBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let size = 0; let tooLarge = false; req.on('data', (chunk: Buffer | string) => { if (tooLarge) return; const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += buffer.length; if (size > MAX_BODY_BYTES) { tooLarge = true; reject(new HarborError('REQUEST_INVALID')); return; } chunks.push(buffer); }); req.on('end', () => { if (tooLarge) return; if (!chunks.length) return resolve({}); try { resolve(record(JSON.parse(Buffer.concat(chunks).toString('utf8')))); } catch (cause) { reject(new HarborError('REQUEST_INVALID', { cause })); } }); req.on('error', reject); }); } function buildActiveConfig( subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[] = stateStore.read().routeRules, ) { const normalizedConfig = normalizeSubscriptionConfig(subscriptionConfig).config; return buildGatewayConfig(normalizedConfig, selectedServerId, { clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct', routeRules, }); } function buildFailoverCandidate(state: StoredState, source: 'desired' | 'applied' = 'desired') { const policy = source === 'applied' ? state.appliedFailoverPolicy : state.failoverPolicy; if (!policy) throw new HarborError('CONFIG_INVALID'); if ( policy.primary.profileId === policy.reserve.profileId && policy.primary.serverId === policy.reserve.serverId ) throw new HarborError('REQUEST_INVALID'); const channel = (role: 'primary' | 'reserve') => { const target = policy[role]; const profile = state.profiles.find(({ id }) => id === target.profileId); const server = profile?.servers.find(({ id }) => id === target.serverId); if (!profile || !server || !profile.subscriptionConfig) throw new HarborError('SERVER_NOT_FOUND'); return { profile, server }; }; const primary = channel('primary'); const reserve = channel('reserve'); const applied = { primary: policy.primary, reserve: policy.reserve, primaryConfigFingerprint: fingerprintSelectedOutbound(primary.profile.subscriptionConfig, primary.server.id), reserveConfigFingerprint: fingerprintSelectedOutbound(reserve.profile.subscriptionConfig, reserve.server.id), }; if (source === 'applied' && JSON.stringify(applied) !== JSON.stringify(state.appliedFailoverPolicy)) { throw new HarborError('CONFIG_INVALID'); } const defaultRole = source === 'applied' && state.appliedProfileId === policy.reserve.profileId && state.appliedServerId === policy.reserve.serverId ? 'reserve' : 'primary'; return { config: buildDualChannelGatewayConfig({ primary: { subscriptionConfig: primary.profile.subscriptionConfig, selectedServerId: primary.server.id }, reserve: { subscriptionConfig: reserve.profile.subscriptionConfig, selectedServerId: reserve.server.id }, }, { routeRules: state.routeRules, defaultRole }), applied, primaryProfile: primary.profile, primaryServer: primary.server, }; } const stopSingbox = () => singboxRuntime.stop(); const startSingbox = () => singboxRuntime.apply(); function writeCurrentConfig() { const state = normalizeStoredState(stateStore.read()); const hasAppliedTarget = Boolean(state.appliedProfileId && state.appliedServerId); const profile = hasAppliedTarget ? state.profiles.find((candidate) => candidate.id === state.appliedProfileId) || null : desiredProfile(state); const serverId = hasAppliedTarget ? state.appliedServerId : profile?.desiredServerId; const server = profile?.servers.find((candidate) => candidate.id === serverId); const subscriptionConfig = profile ? readProfileConfig(profile.id) : null; if (!profile || !server || !subscriptionConfig) return null; let activeConfig: unknown; if (state.appliedFailoverPolicy) { const candidate = buildFailoverCandidate(state, 'applied'); activeConfig = candidate.config; } else { activeConfig = buildActiveConfig(subscriptionConfig, server.id); } const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; try { writeSingboxConfig(activeConfig); } catch (error) { try { if (previousConfig === null) removeSingboxConfig(); else restoreSingboxConfig(previousConfig); } catch (rollbackError) { throw new AggregateError([error, rollbackError], 'Current config rollback failed'); } throw error; } return { profile, server, failoverApplied: state.appliedFailoverPolicy }; } const CONFIG_PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); function currentConfigMatchesAppliedTarget(state: StoredState) { if (!state.appliedProfileId || !state.appliedServerId || !state.appliedServerSnapshot) return false; let config: Record; try { config = record(JSON.parse(fs.readFileSync(settings.configPath, 'utf8'))); } catch { return false; } if (state.appliedFailoverPolicy) { const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId && state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId ? 'reserve' : 'primary'; return dualChannelConfigMatchesApplied(config, state.appliedFailoverPolicy, expectedRole); } const proxyOutbounds = (Array.isArray(config.outbounds) ? config.outbounds : []) .map(record) .filter((outbound) => CONFIG_PROXY_TYPES.has(String(outbound.type || ''))); const exactMatches = proxyOutbounds.filter((outbound) => ( String(outbound.tag || '') === state.appliedServerId )); const targetMatches = exactMatches.length ? exactMatches : proxyOutbounds.filter((outbound) => ( serverIdentityKey(outbound) === serverIdentityKey(state.appliedServerSnapshot) )); if (targetMatches.length !== 1) return false; const outboundTag = String(targetMatches[0].tag || ''); const routeFinal = String(record(config.route).final || ''); const expectsGatewayDirect = settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct'; return expectsGatewayDirect ? routeFinal === 'direct' : routeFinal === outboundTag; } async function reconcileStoppedBoot({ removeConfig = false } = {}) { try { await stopSingbox(); } catch (error) { console.warn(`[control] sing-box не остановлен при startup reconcile: ${errorMessage(error)}`); return; } if (removeConfig) removeSingboxConfig(); const state = normalizeStoredState(stateStore.read()); if ( state.connectionDesired !== 'stopped' || state.appliedProfileId || state.appliedServerId || state.appliedServerSnapshot || state.appliedFailoverPolicy ) { updateStoredState((current) => ({ ...current, connectionDesired: 'stopped', appliedProfileId: '', appliedServerId: '', appliedServerSnapshot: null, appliedFailoverPolicy: null, })); } } async function handleApi(req: IncomingMessage, res: ServerResponse) { if (await stateRoute.handle(req, res)) return; if (await subscriptionValidationRoute.handle(req, res)) return; if (await subscriptionMutationRoute.handle(req, res)) return; if (await serverHealthRoute.handle(req, res)) return; if (await serverApplyRoute.handle(req, res)) return; if (await connectionRuntimeRoute.handle(req, res)) return; if (await routeRulesRoute.handle(req, res)) return; if (await gatewayAutoRoute.handle(req, res)) return; if (await failoverRoute.handle(req, res)) return; if (await activityJournalRoute.handle(req, res)) return; if (await connectivityDiagnosticsRoute.handle(req, res)) return; if (await versionRoute.handle(req, res)) return; if (await sharedProxyRoute.handle(req, res)) return; if (await deviceInventoryRoute.handle(req, res)) return; if (await gatewayPresenceRoute.handle(req, res)) return; return sendError(res, new HarborError('ENDPOINT_NOT_FOUND')); } const mime: Record = { '.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: IncomingMessage, res: ServerResponse) { 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 { if (await prometheusMetricsRoute.handle(req, res)) return; const requestUrl = new URL(req.url || '/', `http://localhost:${settings.port}`); return requestUrl.pathname.startsWith('/api/') ? await handleApi(req, res) : serveStatic(req, res); } catch (error) { return sendError(res, error); } }); async function shutdown() { subscriptionService.stopAutoRefresh(); gatewayAutoService.stopDiscovery(); if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer); await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`)); await serializeControl(() => singboxRuntime.shutdown()); process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); await gatewayAutoService.refresh({ reconfigure: false }) .catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`)); const bootState = normalizeStoredState(stateStore.read()); const bootWantsRunning = bootState.connectionDesired === 'running' || (bootState.connectionDesired === undefined && fs.existsSync(settings.configPath)); if (bootWantsRunning) { let target: ReturnType = null; try { target = writeCurrentConfig(); } catch (error) { console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`); } const canReuseCurrentConfig = target === null && fs.existsSync(settings.configPath) && currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read())); if (target || canReuseCurrentConfig) { await startSingbox() .then(async () => { const current = normalizeStoredState(stateStore.read()); const bootRole = current.appliedFailoverPolicy && current.appliedProfileId === current.appliedFailoverPolicy.reserve.profileId && current.appliedServerId === current.appliedFailoverPolicy.reserve.serverId ? 'reserve' : current.appliedFailoverPolicy ? 'primary' : null; if (bootRole) await failoverDataplane.selectFailoverRole(bootRole); const appliedProfile = target?.profile || current.profiles.find((profile) => profile.id === current.appliedProfileId); const appliedServer = target?.server || current.appliedServerSnapshot; if (appliedProfile && appliedServer) { updateStoredState((state: StoredState) => ({ ...state, connectionDesired: 'running', appliedProfileId: appliedProfile.id, appliedServerId: appliedServer.id, appliedServerSnapshot: appliedServer, ...(settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct' ? { appliedRouteRules: [] } : target ? { appliedRouteRules: state.routeRules } : {}), })); } }) .catch(async (error: unknown) => { console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`); await reconcileStoppedBoot(); }); } else { await reconcileStoppedBoot({ removeConfig: true }); } } else { await reconcileStoppedBoot(); } await gatewayFailover?.reconcile() .catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)); if (deviceInventory) { await deviceInventory.reconcilePolicies() .catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`)); } server.listen(settings.port, '0.0.0.0', () => { console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); }); subscriptionService.startAutoRefresh(SUBSCRIPTION_REFRESH_INTERVAL_MS); gatewayAutoService.startDiscovery(GATEWAY_DISCOVERY_INTERVAL_MS); if (deviceInventory) { deviceInventory.refresh() .catch((error: unknown) => console.warn(`[control] device inventory не обновлён: ${errorMessage(error)}`)); deviceDiscoveryTimer = setInterval(() => { deviceInventory.refresh() .catch((error: unknown) => console.warn(`[control] device inventory не обновлён: ${errorMessage(error)}`)); }, DEVICE_DISCOVERY_INTERVAL_MS); deviceDiscoveryTimer.unref(); }