Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
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 {
|
||||
buildGatewayConfig,
|
||||
removeSingboxConfig,
|
||||
restoreSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import {
|
||||
fetchSubscription,
|
||||
getHwid,
|
||||
normalizeSubscriptionConfig,
|
||||
selectRefreshedServer,
|
||||
} from './subscription.js';
|
||||
import {
|
||||
normalizeStoredState,
|
||||
type OperationState,
|
||||
type RouteRule,
|
||||
type StoredState,
|
||||
} from '../shared/contracts/state.js';
|
||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||
import { 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';
|
||||
|
||||
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 stateStore = createStateStore(settings.statePath);
|
||||
const subscriptionCacheStore = createJsonStore({
|
||||
filePath: settings.subscriptionCachePath,
|
||||
defaultValue: null,
|
||||
});
|
||||
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 cacheRecoveryLogged = false;
|
||||
|
||||
function readRawSubscriptionCache() {
|
||||
const cached = subscriptionCacheStore.read();
|
||||
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
||||
cacheRecoveryLogged = true;
|
||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function readSubscriptionCache() {
|
||||
const raw = readRawSubscriptionCache();
|
||||
const cached = record(raw);
|
||||
return cached.config
|
||||
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
|
||||
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
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: () => readSubscriptionCache()?.config || null,
|
||||
},
|
||||
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(),
|
||||
},
|
||||
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 stateService = createStateService({
|
||||
appMode: settings.appMode,
|
||||
readStoredState: () => stateStore.read(),
|
||||
refreshRuntime: () => singboxRuntime.refresh(),
|
||||
getGatewayAutoState: gatewayAutoService.read,
|
||||
getOperationState: () => operationState,
|
||||
configExists: () => fs.existsSync(settings.configPath),
|
||||
});
|
||||
const stateRoute = createStateRoute({
|
||||
stateService,
|
||||
port: settings.port,
|
||||
proxyPort: settings.proxyPort,
|
||||
});
|
||||
const gatewayAutoRoute = createGatewayAutoRoute({
|
||||
appMode: settings.appMode,
|
||||
gatewayAuto: gatewayAutoService,
|
||||
readBody,
|
||||
withOperation,
|
||||
readStatePayload: stateRoute.readPayload,
|
||||
});
|
||||
const deviceInventoryRoute = createDeviceInventoryRoute({
|
||||
deviceInventory,
|
||||
readBody,
|
||||
});
|
||||
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
||||
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => stateStore.read(),
|
||||
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,
|
||||
});
|
||||
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
||||
appMode: settings.appMode,
|
||||
readState: () => 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,
|
||||
},
|
||||
cache: {
|
||||
read: readRawSubscriptionCache,
|
||||
write: (value) => { subscriptionCacheStore.write(value); },
|
||||
remove: () => subscriptionCacheStore.remove(),
|
||||
},
|
||||
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,
|
||||
},
|
||||
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)}`),
|
||||
});
|
||||
const subscriptionMutationRoute = createSubscriptionMutationRoute({
|
||||
subscriptionService,
|
||||
readBody,
|
||||
withOperation,
|
||||
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||
});
|
||||
const serverHealthRoute = createServerHealthRoute({
|
||||
serverHealth: createServerHealthService({
|
||||
readServers: () => normalizeStoredState(stateStore.read()).servers,
|
||||
ping: tcpPing,
|
||||
}),
|
||||
readBody,
|
||||
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||
});
|
||||
const connectionService = createConnectionService({
|
||||
state: {
|
||||
read: () => normalizeStoredState(stateStore.read()),
|
||||
update: updateStoredState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => readSubscriptionCache()?.config || null,
|
||||
},
|
||||
config: {
|
||||
exists: () => fs.existsSync(settings.configPath),
|
||||
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,
|
||||
},
|
||||
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 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: () => readSubscriptionCache()?.config || null,
|
||||
},
|
||||
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,
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||
applyCommand: () => captureRuntimeCommand(
|
||||
() => startSingbox(),
|
||||
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||
),
|
||||
restoreRunning: () => startSingbox(),
|
||||
},
|
||||
serialize: serializeControl,
|
||||
runOperation: (operation) => withOperation('route-rules', operation),
|
||||
});
|
||||
const routeRulesRoute = createRouteRulesRoute({
|
||||
routeRules: routeRulesService,
|
||||
readBody,
|
||||
sendState: (res) => stateRoute.send(res),
|
||||
});
|
||||
|
||||
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
|
||||
return 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: () => Promise<T>): Promise<T> {
|
||||
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<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,
|
||||
) {
|
||||
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||
clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
|
||||
routeRules,
|
||||
});
|
||||
}
|
||||
|
||||
const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
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 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 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 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)}`));
|
||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||
try {
|
||||
writeCurrentConfig();
|
||||
} catch (error) {
|
||||
const candidate = record(error);
|
||||
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||
}
|
||||
}
|
||||
await startSingbox()
|
||||
.then(() => {
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${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();
|
||||
}
|
||||
Reference in New Issue
Block a user