1093 lines
42 KiB
TypeScript
1093 lines
42 KiB
TypeScript
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<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
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<string, unknown> = {};
|
|
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<InventoryState>({
|
|
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<unknown> = 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<string, unknown>) {
|
|
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<T>(
|
|
kind: string,
|
|
operation: (operationRevision: number) => Promise<T>,
|
|
{
|
|
expectedRevision,
|
|
profileId = null,
|
|
serverId = null,
|
|
}: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {},
|
|
): Promise<T> {
|
|
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<T>(operation: () => Promise<T>): Promise<T> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
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<string, string> = {
|
|
'.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<typeof writeCurrentConfig> = 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();
|
|
}
|