Refactor VPN proxy client implementation
This commit is contained in:
@@ -5,21 +5,44 @@ const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
|
||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
||||
|
||||
export function isDeviceInterface(value) {
|
||||
interface NeighborEntry extends Record<string, unknown> {
|
||||
state?: unknown;
|
||||
lladdr?: unknown;
|
||||
dev?: unknown;
|
||||
dst?: unknown;
|
||||
}
|
||||
|
||||
export interface NeighborObservation {
|
||||
ip: string;
|
||||
mac: string;
|
||||
interface: string;
|
||||
active: boolean;
|
||||
observedAt: string;
|
||||
source: 'neighbor';
|
||||
}
|
||||
|
||||
function neighborEntry(value: unknown): NeighborEntry {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as NeighborEntry
|
||||
: {};
|
||||
}
|
||||
|
||||
export function isDeviceInterface(value: unknown) {
|
||||
const name = String(value || '');
|
||||
return INTERFACE_PATTERN.test(name)
|
||||
&& name !== 'docker0' && !name.startsWith('br-') && !name.startsWith('veth');
|
||||
}
|
||||
|
||||
export function parseNeighborSnapshot(value, observedAt = new Date().toISOString()) {
|
||||
export function parseNeighborSnapshot(value: unknown, observedAt = new Date().toISOString()): NeighborObservation[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
const states = (Array.isArray(entry?.state) ? entry.state : [entry?.state])
|
||||
return value.flatMap((value) => {
|
||||
const entry = neighborEntry(value);
|
||||
const states = (Array.isArray(entry.state) ? entry.state : [entry.state])
|
||||
.filter(Boolean)
|
||||
.map((state) => String(state).toUpperCase());
|
||||
const mac = String(entry?.lladdr || '').toLowerCase();
|
||||
const deviceInterface = String(entry?.dev || '');
|
||||
if (!entry?.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
||||
.map((state: unknown) => String(state).toUpperCase());
|
||||
const mac = String(entry.lladdr || '').toLowerCase();
|
||||
const deviceInterface = String(entry.dev || '');
|
||||
if (!entry.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
||||
|| states.some((state) => IGNORED_STATES.has(state))) {
|
||||
return [];
|
||||
}
|
||||
@@ -34,7 +57,7 @@ export function parseNeighborSnapshot(value, observedAt = new Date().toISOString
|
||||
});
|
||||
}
|
||||
|
||||
export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) {
|
||||
export function readNeighborSnapshot(run: typeof spawnSync = spawnSync, now = () => new Date()) {
|
||||
const observedAt = now().toISOString();
|
||||
const result = run('ip', ['-j', 'neigh', 'show'], {
|
||||
encoding: 'utf8',
|
||||
@@ -54,6 +77,7 @@ export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) {
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${error.message}` };
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${message}` };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||
const parsePort = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
const parsePort = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
return Number.isInteger(parsed) ? parsed : fallback;
|
||||
};
|
||||
const proxyPort = parsePort(
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { settings } from './config.js';
|
||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||
import { buildVersionInfo } from './version.js';
|
||||
@@ -40,23 +41,34 @@ const domainTraffic = createDomainTrafficService({
|
||||
devices: () => traffic.snapshot().devices,
|
||||
});
|
||||
let ready = false;
|
||||
let trafficTimer = null;
|
||||
let domainTrafficTimer = null;
|
||||
let trafficTimer: NodeJS.Timeout | null = null;
|
||||
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||
const MAX_POLICY_BODY_BYTES = 256 * 1024;
|
||||
|
||||
function readJson(req) {
|
||||
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);
|
||||
}
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
req.on('data', (chunk: Buffer | string) => {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (!tooLarge && size > MAX_POLICY_BODY_BYTES) {
|
||||
tooLarge = true;
|
||||
reject(new Error('Device policy request слишком большой'));
|
||||
return;
|
||||
}
|
||||
if (!tooLarge) chunks.push(chunk);
|
||||
if (!tooLarge) chunks.push(buffer);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (tooLarge) return;
|
||||
@@ -70,12 +82,12 @@ function readJson(req) {
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
try {
|
||||
if (req.method === 'GET' && req.url === '/status') {
|
||||
return sendJson(res, ready ? 200 : 503, {
|
||||
@@ -99,11 +111,11 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendJson(res, 200, devicePolicy.snapshot());
|
||||
}
|
||||
if (req.method === 'PUT' && req.url === '/device-policy') {
|
||||
const body = await readJson(req);
|
||||
const body = record(await readJson(req));
|
||||
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
||||
const { services = [], target = null } = await readJson(req);
|
||||
const { services = [], target = null } = record(await readJson(req));
|
||||
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||
vpnAvailable: runtime.running,
|
||||
services,
|
||||
@@ -121,7 +133,7 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
return sendJson(res, 404, { error: 'Не найдено' });
|
||||
} catch (error) {
|
||||
return sendJson(res, 500, { error: error.message || String(error) });
|
||||
return sendJson(res, 500, { error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -132,25 +144,25 @@ server.listen(socketPath, async () => {
|
||||
try {
|
||||
await runtime.apply();
|
||||
} catch (error) {
|
||||
console.warn(`[dataplane] sing-box не запущен: ${error.message}`);
|
||||
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
ready = true;
|
||||
setImmediate(() => {
|
||||
traffic.refresh()
|
||||
.catch((error) => console.warn(`[dataplane] traffic counters не запущены: ${error.message}`));
|
||||
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||
});
|
||||
trafficTimer = setInterval(() => {
|
||||
traffic.refresh().catch((error) => console.warn(`[dataplane] traffic counters не обновлены: ${error.message}`));
|
||||
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||
}, 15_000);
|
||||
trafficTimer.unref();
|
||||
setImmediate(() => {
|
||||
domainTraffic.refresh()
|
||||
.catch((error) => console.warn(`[dataplane] domain traffic не запущен: ${error.message}`));
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||
});
|
||||
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
|
||||
domainTrafficTimer = setInterval(() => {
|
||||
domainTraffic.refresh()
|
||||
.catch((error) => console.warn(`[dataplane] domain traffic не обновлён: ${error.message}`));
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||
}, 2_000);
|
||||
domainTrafficTimer.unref();
|
||||
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||
@@ -1,7 +1,25 @@
|
||||
import http from 'node:http';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
function request(socketPath, pathname, method = 'GET', body = null, timeoutMs = 6000) {
|
||||
type SendDataplaneRequest = (
|
||||
socketPath: string,
|
||||
pathname: string,
|
||||
method?: string,
|
||||
body?: unknown,
|
||||
timeoutMs?: number,
|
||||
) => Promise<unknown>;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function request(
|
||||
socketPath: string,
|
||||
pathname: string,
|
||||
method = 'GET',
|
||||
body: unknown = null,
|
||||
timeoutMs = 6000,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const encoded = body == null ? null : JSON.stringify(body);
|
||||
const req = http.request({
|
||||
@@ -13,17 +31,17 @@ function request(socketPath, pathname, method = 'GET', body = null, timeoutMs =
|
||||
'content-length': Buffer.byteLength(encoded),
|
||||
} : {},
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
let body = {};
|
||||
let body: unknown = {};
|
||||
try {
|
||||
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
} catch {
|
||||
return reject(new Error('Dataplane вернул невалидный JSON'));
|
||||
}
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
return reject(new Error(body.error || `Dataplane HTTP ${res.statusCode}`));
|
||||
return reject(new Error(String(record(body).error || `Dataplane HTTP ${res.statusCode}`)));
|
||||
}
|
||||
resolve(body);
|
||||
});
|
||||
@@ -34,11 +52,11 @@ function request(socketPath, pathname, method = 'GET', body = null, timeoutMs =
|
||||
});
|
||||
}
|
||||
|
||||
export function createDataplaneClient(socketPath, send = request) {
|
||||
let current = { running: false, startedAt: null };
|
||||
const update = async (pathname, method) => {
|
||||
export function createDataplaneClient(socketPath: string, send: SendDataplaneRequest = request) {
|
||||
let current: Record<string, unknown> = { running: false, startedAt: null };
|
||||
const update = async (pathname: string, method: string) => {
|
||||
try {
|
||||
current = await send(socketPath, pathname, method);
|
||||
current = record(await send(socketPath, pathname, method));
|
||||
return current;
|
||||
} catch (cause) {
|
||||
if (pathname === '/apply' || pathname === '/restart') {
|
||||
@@ -56,8 +74,8 @@ export function createDataplaneClient(socketPath, send = request) {
|
||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||
applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||
runConnectivityDiagnostics: async (services = [], target = null) => {
|
||||
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||
try {
|
||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
||||
} catch (cause) {
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface ConnectionServiceDependencies {
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: {
|
||||
readConfig(): unknown | null;
|
||||
};
|
||||
config: {
|
||||
exists(): boolean;
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
restore(value: string): void;
|
||||
remove(): void;
|
||||
};
|
||||
runtime: {
|
||||
isRunning(): Promise<boolean>;
|
||||
start(): Promise<unknown>;
|
||||
stop(): Promise<unknown>;
|
||||
stopCommand(): Promise<RuntimeCommandResult>;
|
||||
restartCommand(): Promise<RuntimeCommandResult>;
|
||||
};
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export type RuntimeCommandResult =
|
||||
| { ok: true; mutationStarted: true }
|
||||
| { ok: false; mutationStarted: boolean; error: unknown };
|
||||
|
||||
export async function captureRuntimeCommand(
|
||||
command: () => Promise<unknown>,
|
||||
{ preMutationErrorCodes = [] }: { preMutationErrorCodes?: readonly string[] } = {},
|
||||
): Promise<RuntimeCommandResult> {
|
||||
try {
|
||||
await command();
|
||||
return { ok: true, mutationStarted: true };
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||
return { ok: false, mutationStarted: !preMutationErrorCodes.includes(code), error };
|
||||
}
|
||||
}
|
||||
|
||||
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
|
||||
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
const requestedId = String(serverId).trim();
|
||||
const requestedTag = String(selectedTag).trim();
|
||||
const resolvedId = requestedId || (() => {
|
||||
const matches = previousState.servers.filter((server) => server.label === requestedTag);
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
})();
|
||||
const selectedServer = previousState.servers.find((server) => server.id === resolvedId);
|
||||
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||
|
||||
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
selectedServer.id,
|
||||
previousState.routeRules,
|
||||
);
|
||||
const previousConfig = dependencies.config.read();
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
let desiredCommitStarted = false;
|
||||
let configMutationStarted = false;
|
||||
|
||||
try {
|
||||
desiredCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
selectedServerId: selectedServer.id,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(nextConfig);
|
||||
await dependencies.runtime.start();
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
appliedServerId: selectedServer.id,
|
||||
appliedAt: dependencies.now().toISOString(),
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(configMutationStarted ? [{
|
||||
run: () => previousConfig === null
|
||||
? dependencies.config.remove()
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(configMutationStarted ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
|
||||
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
});
|
||||
|
||||
const stop = () => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
let wasRunning: boolean | null = null;
|
||||
try {
|
||||
wasRunning = await dependencies.runtime.isRunning();
|
||||
} catch {}
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
const command = await dependencies.runtime.stopCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' }));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
});
|
||||
|
||||
const restart = () => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID');
|
||||
let wasRunning: boolean | null = null;
|
||||
try {
|
||||
wasRunning = await dependencies.runtime.isRunning();
|
||||
} catch {}
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
const command = await dependencies.runtime.restartCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
appliedServerId: state.selectedServerId,
|
||||
connectionDesired: 'running',
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
});
|
||||
|
||||
return { apply, stop, restart };
|
||||
}
|
||||
|
||||
export type ConnectionService = ReturnType<typeof createConnectionService>;
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
captureRuntimeCommand,
|
||||
createConnectionService,
|
||||
type RuntimeCommandResult,
|
||||
type ConnectionService,
|
||||
} from './connectionService.js';
|
||||
@@ -0,0 +1,52 @@
|
||||
interface DiagnosticServer {
|
||||
id: unknown;
|
||||
label: unknown;
|
||||
}
|
||||
|
||||
interface DiagnosticState {
|
||||
appliedServerId?: unknown;
|
||||
selectedServerId?: unknown;
|
||||
servers?: DiagnosticServer[];
|
||||
}
|
||||
|
||||
interface DiagnosticsResult extends Record<string, unknown> {
|
||||
vpn?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ConnectivityDiagnosticsDependencies {
|
||||
readState(): DiagnosticState;
|
||||
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TypeError('Diagnostics adapter returned an invalid result');
|
||||
}
|
||||
return value as DiagnosticsResult;
|
||||
}
|
||||
|
||||
export function createConnectivityDiagnosticsUseCase(
|
||||
dependencies: ConnectivityDiagnosticsDependencies,
|
||||
) {
|
||||
return {
|
||||
async run(services: unknown, target: unknown) {
|
||||
const state = dependencies.readState();
|
||||
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||
const selected = (Array.isArray(state.servers) ? state.servers : [])
|
||||
.find((server) => server.id === appliedServerId);
|
||||
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||
return {
|
||||
...result,
|
||||
vpn: {
|
||||
...result.vpn,
|
||||
server,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ConnectivityDiagnosticsUseCase = ReturnType<
|
||||
typeof createConnectivityDiagnosticsUseCase
|
||||
>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createConnectivityDiagnosticsUseCase,
|
||||
type ConnectivityDiagnosticsUseCase,
|
||||
} from './connectivityDiagnosticsUseCase.js';
|
||||
@@ -0,0 +1,292 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
|
||||
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface HostNetworkState {
|
||||
gateway: string;
|
||||
interface: string;
|
||||
mac: string;
|
||||
observedAt?: number;
|
||||
}
|
||||
|
||||
interface VerifiedGateway {
|
||||
gatewayId: string;
|
||||
uiOrigin?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
type TimerHandle = NodeJS.Timeout;
|
||||
|
||||
interface GatewayAutoServiceDependencies {
|
||||
appMode: string;
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: { readConfig(): unknown | null };
|
||||
config: {
|
||||
build(
|
||||
subscriptionConfig: unknown,
|
||||
selectedServerId: string,
|
||||
routeRules: StoredState['routeRules'],
|
||||
gatewayAuto: GatewayAutoState,
|
||||
): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
restore(value: string): void;
|
||||
remove(): void;
|
||||
};
|
||||
runtime: {
|
||||
isRunning(): boolean;
|
||||
applyCommand(): Promise<RuntimeCommandResult>;
|
||||
restoreRunning(): Promise<unknown>;
|
||||
};
|
||||
discovery: {
|
||||
readHostNetwork(): HostNetworkState | null;
|
||||
probeGateway(input: {
|
||||
gateway: string;
|
||||
subscriptionUrl: string;
|
||||
}): Promise<VerifiedGateway>;
|
||||
};
|
||||
transition: {
|
||||
createInitial(): GatewayAutoState;
|
||||
applyPreference(state: GatewayAutoState, enabled: boolean): GatewayAutoState;
|
||||
next(
|
||||
current: GatewayAutoState,
|
||||
input: {
|
||||
network: HostNetworkState | null;
|
||||
verifiedGateway?: VerifiedGateway | null;
|
||||
error?: string;
|
||||
},
|
||||
): GatewayAutoState;
|
||||
sameRoute(
|
||||
previous: GatewayAutoState['gateway'] | HostNetworkState | null | undefined,
|
||||
current: HostNetworkState | null | undefined,
|
||||
): boolean;
|
||||
};
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
scheduler: {
|
||||
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||
clearInterval(timer: TimerHandle): void;
|
||||
};
|
||||
onRouteChange(state: GatewayAutoState): void;
|
||||
onDiscoveryWarning(reason: string): void;
|
||||
onTimerError(error: unknown): void;
|
||||
}
|
||||
|
||||
interface CommitOptions {
|
||||
reconfigure?: boolean;
|
||||
persistEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface RefreshOptions {
|
||||
reconfigure?: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error && typeof error === 'object' && 'message' in error && error.message
|
||||
? String(error.message)
|
||||
: 'Gateway presence check failed';
|
||||
}
|
||||
|
||||
export function createGatewayAutoService(dependencies: GatewayAutoServiceDependencies) {
|
||||
let current = dependencies.transition.createInitial();
|
||||
let refreshPromise: Promise<GatewayAutoState> | null = null;
|
||||
let discoveryTimer: TimerHandle | null = null;
|
||||
|
||||
const restoreConfig = (previous: string | null) => {
|
||||
if (previous === null) dependencies.config.remove();
|
||||
else dependencies.config.restore(previous);
|
||||
};
|
||||
|
||||
const commitCandidate = async (
|
||||
candidate: GatewayAutoState,
|
||||
{ reconfigure = true, persistEnabled }: CommitOptions = {},
|
||||
) => {
|
||||
const previousGatewayAuto = current;
|
||||
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
|
||||
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
|
||||
if (!stateChanged && persistEnabled === undefined) return current;
|
||||
|
||||
const previousState = dependencies.state.read();
|
||||
const subscriptionConfig = modeChanged
|
||||
? dependencies.subscription.readConfig()
|
||||
: null;
|
||||
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
|
||||
? dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
previousState.selectedServerId,
|
||||
previousState.routeRules,
|
||||
candidate,
|
||||
)
|
||||
: null;
|
||||
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
|
||||
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let gatewayAutoPublished = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if (candidateConfig !== null) {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
if (reconfigure && wasRunning) {
|
||||
const command = await dependencies.runtime.applyCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) {
|
||||
current = candidate;
|
||||
gatewayAutoPublished = true;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => state);
|
||||
}
|
||||
if (persistEnabled !== undefined) {
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
gatewayAutoEnabled: persistEnabled,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
|
||||
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||
...(wasRunning && runtimeMutationStarted ? [{
|
||||
run: () => dependencies.runtime.restoreRunning(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Gateway auto rollback failed');
|
||||
}
|
||||
|
||||
if (modeChanged) dependencies.onRouteChange(candidate);
|
||||
return current;
|
||||
};
|
||||
|
||||
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
|
||||
const state = dependencies.state.read();
|
||||
const network = state.subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
|
||||
if (!network) {
|
||||
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||
const discoveredState = dependencies.transition.next(current, {
|
||||
network: null,
|
||||
error: discoveryError,
|
||||
});
|
||||
const candidate = dependencies.transition.applyPreference(
|
||||
state.subscriptionUrl
|
||||
? { ...discoveredState, lastError: discoveryError }
|
||||
: discoveredState,
|
||||
state.gatewayAutoEnabled !== false,
|
||||
);
|
||||
return commitCandidate(candidate, { reconfigure });
|
||||
}
|
||||
|
||||
if (
|
||||
current.mode === 'gateway-direct' &&
|
||||
!dependencies.transition.sameRoute(current.gateway, network)
|
||||
) {
|
||||
await commitCandidate(
|
||||
dependencies.transition.next(current, { network }),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
|
||||
let verifiedGateway: VerifiedGateway;
|
||||
try {
|
||||
verifiedGateway = await dependencies.discovery.probeGateway({
|
||||
gateway: network.gateway,
|
||||
subscriptionUrl: String(state.subscriptionUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = errorMessage(error);
|
||||
const latestState = dependencies.state.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||
) {
|
||||
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||
}
|
||||
if (current.lastError !== reason) dependencies.onDiscoveryWarning(reason);
|
||||
return commitCandidate(
|
||||
dependencies.transition.applyPreference(
|
||||
dependencies.transition.next(current, {
|
||||
network: latestNetwork,
|
||||
error: reason,
|
||||
}),
|
||||
latestState.gatewayAutoEnabled !== false,
|
||||
),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
|
||||
const latestState = dependencies.state.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||
) {
|
||||
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||
}
|
||||
return commitCandidate(
|
||||
dependencies.transition.applyPreference(
|
||||
dependencies.transition.next(current, { network: latestNetwork, verifiedGateway }),
|
||||
latestState.gatewayAutoEnabled !== false,
|
||||
),
|
||||
{ reconfigure },
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = (options: RefreshOptions = {}) => {
|
||||
if (dependencies.appMode !== 'client') return Promise.resolve(current);
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = dependencies.serialize(() => runRefresh(options)).finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
const setEnabled = (enabled: boolean) => dependencies.serialize(() => commitCandidate(
|
||||
dependencies.transition.applyPreference(current, enabled),
|
||||
{ persistEnabled: enabled },
|
||||
));
|
||||
|
||||
const startDiscovery = (intervalMs: number) => {
|
||||
if (discoveryTimer) return;
|
||||
discoveryTimer = dependencies.scheduler.setInterval(() => {
|
||||
void refresh().catch(dependencies.onTimerError);
|
||||
}, intervalMs);
|
||||
discoveryTimer.unref();
|
||||
};
|
||||
|
||||
const stopDiscovery = () => {
|
||||
if (!discoveryTimer) return;
|
||||
dependencies.scheduler.clearInterval(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
};
|
||||
|
||||
return {
|
||||
read: () => current,
|
||||
set: (value: GatewayAutoState) => { current = value; },
|
||||
createInitial: dependencies.transition.createInitial,
|
||||
setEnabled,
|
||||
refresh,
|
||||
startDiscovery,
|
||||
stopDiscovery,
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewayAutoService = ReturnType<typeof createGatewayAutoService>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createRouteRulesService,
|
||||
type RouteRulesService,
|
||||
} from './routeRulesService.js';
|
||||
export {
|
||||
createGatewayAutoService,
|
||||
type GatewayAutoService,
|
||||
} from './gatewayAutoService.js';
|
||||
@@ -0,0 +1,119 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { RouteRule, StoredState } from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { normalizeRouteRules } from '../../../shared/routingRules.js';
|
||||
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface RouteRulesDependencies {
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: { readConfig(): unknown | null };
|
||||
config: {
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
restore(value: string): void;
|
||||
remove(): void;
|
||||
};
|
||||
runtime: {
|
||||
isRunning(): Promise<boolean>;
|
||||
applyCommand(): Promise<RuntimeCommandResult>;
|
||||
restoreRunning(): Promise<unknown>;
|
||||
};
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
runOperation<T>(operation: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
|
||||
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||
if (!previousState.selectedServerId || !subscriptionConfig) {
|
||||
let stateCommitStarted = false;
|
||||
try {
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
routeRules,
|
||||
routeRulesRevision: state.routeRulesRevision + 1,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Route rules rollback failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateConfig = dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
previousState.selectedServerId,
|
||||
routeRules,
|
||||
);
|
||||
const previousConfig = dependencies.config.read();
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
if (wasRunning) {
|
||||
const command = await dependencies.runtime.applyCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
}
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
routeRules,
|
||||
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||
routeRulesRevision: state.routeRulesRevision + 1,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(configMutationStarted ? [{
|
||||
run: () => previousConfig === null
|
||||
? dependencies.config.remove()
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(wasRunning && runtimeMutationStarted ? [{
|
||||
run: () => dependencies.runtime.restoreRunning(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Route rules rollback failed');
|
||||
}
|
||||
};
|
||||
|
||||
const update = (rules: unknown, expectedRulesRevision: unknown, expectedRevision: unknown) => {
|
||||
let routeRules: RouteRule[];
|
||||
try {
|
||||
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
|
||||
} catch (cause) {
|
||||
throw new HarborError('REQUEST_INVALID', { cause });
|
||||
}
|
||||
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
||||
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
|
||||
return dependencies.serialize(async () => {
|
||||
const current = dependencies.state.read();
|
||||
const currentRevision = expectedRulesRevision == null
|
||||
? current.revision
|
||||
: current.routeRulesRevision;
|
||||
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||
await dependencies.runOperation(() => applyRules(current, routeRules));
|
||||
});
|
||||
};
|
||||
|
||||
return { update };
|
||||
}
|
||||
|
||||
export type RouteRulesService = ReturnType<typeof createRouteRulesService>;
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
checkServerHealth,
|
||||
createServerHealthService,
|
||||
SERVER_HEALTH_CONCURRENCY,
|
||||
SERVER_HEALTH_MAX_COUNT,
|
||||
type ServerHealthService,
|
||||
} from './serverHealth.js';
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { HarborServer } from '../../../shared/contracts/state.js';
|
||||
|
||||
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||
|
||||
type HealthServer = Pick<HarborServer, 'id' | 'label' | 'host' | 'port'>;
|
||||
type Ping = (host: string, port: number) => Promise<Record<string, unknown>>;
|
||||
|
||||
export async function checkServerHealth(
|
||||
servers: HealthServer[],
|
||||
ping: Ping,
|
||||
{
|
||||
maxCount = SERVER_HEALTH_MAX_COUNT,
|
||||
concurrency = SERVER_HEALTH_CONCURRENCY,
|
||||
} = {},
|
||||
) {
|
||||
const queue = servers.slice(0, maxCount);
|
||||
const results: Array<Record<string, unknown>> = new Array(queue.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < queue.length) {
|
||||
const index = nextIndex++;
|
||||
const server = queue[index];
|
||||
results[index] = {
|
||||
id: server.id,
|
||||
tag: server.label,
|
||||
...await ping(server.host, server.port),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
interface ServerHealthDependencies {
|
||||
readServers(): HarborServer[];
|
||||
ping: Ping;
|
||||
}
|
||||
|
||||
export function createServerHealthService(dependencies: ServerHealthDependencies) {
|
||||
return {
|
||||
check(serverIds: unknown) {
|
||||
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||
const servers = dependencies.readServers();
|
||||
return checkServerHealth(
|
||||
requestedIds.size
|
||||
? servers.filter((server) => requestedIds.has(server.id))
|
||||
: servers,
|
||||
dependencies.ping,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ServerHealthService = ReturnType<typeof createServerHealthService>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
createStateSnapshot,
|
||||
normalizeStoredState,
|
||||
type GatewayAutoState,
|
||||
type OperationState,
|
||||
type StateSnapshot,
|
||||
type StoredState,
|
||||
} from '../../../shared/contracts/state.js';
|
||||
|
||||
interface RuntimeState {
|
||||
running?: boolean;
|
||||
startedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface StateReadResult {
|
||||
snapshot: StateSnapshot;
|
||||
storedState: StoredState;
|
||||
gatewayAuto: GatewayAutoState;
|
||||
configExists: boolean;
|
||||
}
|
||||
|
||||
interface StateServiceDependencies {
|
||||
appMode: string;
|
||||
readStoredState: () => unknown;
|
||||
refreshRuntime: () => Promise<RuntimeState>;
|
||||
getGatewayAutoState: () => GatewayAutoState;
|
||||
getOperationState: () => OperationState;
|
||||
configExists: () => boolean;
|
||||
}
|
||||
|
||||
function subscriptionHost(value: unknown) {
|
||||
try {
|
||||
return `${new URL(String(value)).host}/…`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createStateService(dependencies: StateServiceDependencies) {
|
||||
return {
|
||||
async read(): Promise<StateReadResult> {
|
||||
const runtime = await dependencies.refreshRuntime();
|
||||
const storedState = normalizeStoredState(dependencies.readStoredState());
|
||||
const gatewayAuto = dependencies.getGatewayAutoState();
|
||||
const configExists = dependencies.configExists();
|
||||
const snapshot = createStateSnapshot({
|
||||
storedState,
|
||||
runtime,
|
||||
gatewayAuto,
|
||||
appMode: dependencies.appMode,
|
||||
configExists,
|
||||
subscriptionHost: subscriptionHost(storedState.subscriptionUrl),
|
||||
operation: dependencies.getOperationState(),
|
||||
});
|
||||
return { snapshot, storedState, gatewayAuto, configExists };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StateService = ReturnType<typeof createStateService>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createValidateSubscription,
|
||||
type ValidateSubscription,
|
||||
} from './validateSubscription.js';
|
||||
export {
|
||||
createSubscriptionService,
|
||||
type SubscriptionService,
|
||||
} from './subscriptionService.js';
|
||||
@@ -0,0 +1,289 @@
|
||||
import type {
|
||||
GatewayAutoState,
|
||||
HarborServer,
|
||||
StoredState,
|
||||
} from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface ParsedSubscription {
|
||||
config: unknown;
|
||||
sourceConfig?: unknown;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
type TimerHandle = NodeJS.Timeout;
|
||||
|
||||
interface SubscriptionServiceDependencies {
|
||||
provider: {
|
||||
fetchSubscription(url: string): Promise<ParsedSubscription>;
|
||||
selectRefreshedServer(
|
||||
currentServerId: string,
|
||||
currentServers: HarborServer[],
|
||||
nextServers: HarborServer[],
|
||||
): string;
|
||||
};
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
cache: {
|
||||
read(): unknown;
|
||||
write(value: unknown): void;
|
||||
remove(): void;
|
||||
};
|
||||
config: {
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
restore(value: string): void;
|
||||
remove(): void;
|
||||
};
|
||||
runtime: {
|
||||
isRunning(): Promise<boolean>;
|
||||
stop(): Promise<unknown>;
|
||||
start(): Promise<unknown>;
|
||||
};
|
||||
gatewayAuto: {
|
||||
read(): GatewayAutoState;
|
||||
set(value: GatewayAutoState): void;
|
||||
createInitial(): GatewayAutoState;
|
||||
};
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
scheduler: {
|
||||
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||
clearInterval(handle: TimerHandle): void;
|
||||
};
|
||||
onRefreshError(error: unknown): void;
|
||||
}
|
||||
|
||||
interface ResetOptions {
|
||||
stopRuntime?: boolean;
|
||||
expectedSubscription?: {
|
||||
url: string;
|
||||
generation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscriptionMutationResult extends Record<string, unknown> {
|
||||
success: true;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
fetchedAt: string;
|
||||
selectedServerId: string;
|
||||
selectedTag: string;
|
||||
}
|
||||
|
||||
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||
'SUBSCRIPTION_EXPIRED',
|
||||
'SUBSCRIPTION_DISABLED',
|
||||
'SUBSCRIPTION_REJECTED',
|
||||
]);
|
||||
|
||||
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
|
||||
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
|
||||
let refreshTimer: TimerHandle | null = null;
|
||||
let subscriptionGeneration = 0;
|
||||
|
||||
const restoreCache = (previous: unknown) => {
|
||||
if (previous !== null) dependencies.cache.write(previous);
|
||||
else dependencies.cache.remove();
|
||||
};
|
||||
|
||||
const restoreConfig = (previous: string | null) => {
|
||||
if (previous === null) dependencies.config.remove();
|
||||
else dependencies.config.restore(previous);
|
||||
};
|
||||
|
||||
const commitSubscription = (
|
||||
subscriptionUrl: string,
|
||||
parsed: ParsedSubscription,
|
||||
{ resetSelection = false, expectedGeneration }: {
|
||||
resetSelection?: boolean;
|
||||
expectedGeneration?: number;
|
||||
} = {},
|
||||
) => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
if (
|
||||
subscriptionGeneration !== expectedGeneration ||
|
||||
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
|
||||
) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedServerId = resetSelection
|
||||
? ''
|
||||
: dependencies.provider.selectRefreshedServer(
|
||||
previousState.selectedServerId,
|
||||
previousState.servers,
|
||||
parsed.servers,
|
||||
);
|
||||
const candidateConfig = selectedServerId
|
||||
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
|
||||
: null;
|
||||
const previousCache = dependencies.cache.read();
|
||||
const previousConfig = dependencies.config.read();
|
||||
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
let restoreRuntime = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if ((resetSelection || !candidateConfig) && wasRunning) {
|
||||
restoreRuntime = true;
|
||||
await dependencies.runtime.stop();
|
||||
}
|
||||
if (candidateConfig) dependencies.config.write(candidateConfig);
|
||||
else dependencies.config.remove();
|
||||
dependencies.cache.write({
|
||||
url: subscriptionUrl,
|
||||
config: parsed.sourceConfig || parsed.config,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
});
|
||||
if (!resetSelection && wasRunning && candidateConfig) {
|
||||
restoreRuntime = true;
|
||||
await dependencies.runtime.start();
|
||||
}
|
||||
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...(resetSelection ? {
|
||||
routeRules: state.routeRules,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
connectionDesired: 'stopped',
|
||||
} : state),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedServerId,
|
||||
appliedServerId: selectedServerId,
|
||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
subscriptionGeneration += 1;
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||
{ run: () => restoreCache(previousCache) },
|
||||
{ run: () => restoreConfig(previousConfig) },
|
||||
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||
], 'Subscription rollback failed');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedServerId,
|
||||
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
||||
};
|
||||
});
|
||||
|
||||
const importSubscription = async (subscriptionUrl: string) => {
|
||||
const expectedGeneration = subscriptionGeneration;
|
||||
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration });
|
||||
};
|
||||
|
||||
const resetSavedSubscription = ({
|
||||
stopRuntime = true,
|
||||
expectedSubscription,
|
||||
}: ResetOptions = {}) => (
|
||||
dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
if (expectedSubscription && (
|
||||
previousState.subscriptionUrl !== expectedSubscription.url ||
|
||||
subscriptionGeneration !== expectedSubscription.generation
|
||||
)) return false;
|
||||
const previousCache = dependencies.cache.read();
|
||||
const previousConfig = dependencies.config.read();
|
||||
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
|
||||
let restoreRuntime = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if (stopRuntime) {
|
||||
restoreRuntime = wasRunning;
|
||||
await dependencies.runtime.stop();
|
||||
}
|
||||
dependencies.config.remove();
|
||||
dependencies.cache.remove();
|
||||
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
|
||||
subscriptionGeneration += 1;
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||
{ run: () => restoreCache(previousCache) },
|
||||
{ run: () => restoreConfig(previousConfig) },
|
||||
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||
], 'Subscription rollback failed');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
const refreshSavedSubscription = () => {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
|
||||
const expectedGeneration = subscriptionGeneration;
|
||||
const operation = (async () => {
|
||||
try {
|
||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: '';
|
||||
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
|
||||
const reset = await resetSavedSubscription({
|
||||
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
|
||||
});
|
||||
if (!reset) throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
refreshPromise = operation;
|
||||
return operation;
|
||||
};
|
||||
|
||||
const startAutoRefresh = (intervalMs: number) => {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = dependencies.scheduler.setInterval(() => {
|
||||
if (!dependencies.state.read().subscriptionUrl) return;
|
||||
void refreshSavedSubscription().catch(dependencies.onRefreshError);
|
||||
}, intervalMs);
|
||||
refreshTimer.unref();
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (!refreshTimer) return;
|
||||
dependencies.scheduler.clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
};
|
||||
|
||||
return {
|
||||
importSubscription,
|
||||
refreshSavedSubscription,
|
||||
resetSavedSubscription,
|
||||
startAutoRefresh,
|
||||
stopAutoRefresh,
|
||||
};
|
||||
}
|
||||
|
||||
export type SubscriptionService = ReturnType<typeof createSubscriptionService>;
|
||||
@@ -0,0 +1,14 @@
|
||||
interface SubscriptionResult {
|
||||
servers: unknown[];
|
||||
}
|
||||
|
||||
type FetchSubscription = (url: string) => Promise<SubscriptionResult>;
|
||||
|
||||
export function createValidateSubscription(fetchSubscription: FetchSubscription) {
|
||||
return async (url: unknown) => {
|
||||
const parsed = await fetchSubscription(String(url).trim());
|
||||
return { servers: parsed.servers.length };
|
||||
};
|
||||
}
|
||||
|
||||
export type ValidateSubscription = ReturnType<typeof createValidateSubscription>;
|
||||
@@ -8,14 +8,43 @@ const INTERFACE_RE = /^[a-zA-Z0-9._-]{1,32}$/;
|
||||
const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i;
|
||||
const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']);
|
||||
|
||||
function isIpv4(value) {
|
||||
interface GatewayRoute {
|
||||
gateway: string;
|
||||
interface: string;
|
||||
mac: string;
|
||||
observedAt?: number;
|
||||
}
|
||||
|
||||
interface VerifiedGateway {
|
||||
gatewayId: string;
|
||||
uiOrigin?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayAutoRuntimeState {
|
||||
mode: 'local-vpn' | 'gateway-direct';
|
||||
failures: number;
|
||||
gateway: GatewayRoute | null;
|
||||
gatewayId: string;
|
||||
uiOrigin: string;
|
||||
lastVerifiedAt: string | null;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function isIpv4(value: unknown) {
|
||||
const parts = String(value || '').split('.');
|
||||
return parts.length === 4 && parts.every((part) => (
|
||||
/^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255
|
||||
));
|
||||
}
|
||||
|
||||
function subscriptionSecret(subscriptionUrl) {
|
||||
function subscriptionSecret(subscriptionUrl: unknown) {
|
||||
try {
|
||||
const url = new URL(String(subscriptionUrl || '').trim());
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
@@ -39,7 +68,7 @@ function subscriptionSecret(subscriptionUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
function presenceProof(subscriptionUrl, nonce, gatewayId) {
|
||||
function presenceProof(subscriptionUrl: unknown, nonce: unknown, gatewayId: unknown) {
|
||||
const credentialUrl = subscriptionSecret(subscriptionUrl);
|
||||
if (!credentialUrl) return '';
|
||||
const key = crypto.createHash('sha256')
|
||||
@@ -50,7 +79,12 @@ function presenceProof(subscriptionUrl, nonce, gatewayId) {
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) {
|
||||
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }: {
|
||||
appMode: unknown;
|
||||
subscriptionUrl: unknown;
|
||||
gatewayId: unknown;
|
||||
nonce: unknown;
|
||||
}) {
|
||||
if (!NONCE_RE.test(String(nonce || ''))) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
@@ -79,7 +113,11 @@ export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonc
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) {
|
||||
export function verifyGatewayPresence(
|
||||
value: unknown,
|
||||
{ subscriptionUrl, nonce }: { subscriptionUrl: unknown; nonce: unknown },
|
||||
) {
|
||||
const payload = record(value);
|
||||
if (
|
||||
payload?.available !== true ||
|
||||
payload?.product !== 'harbor' ||
|
||||
@@ -91,7 +129,7 @@ export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) {
|
||||
!PROOF_RE.test(String(payload.proof || ''))
|
||||
) return false;
|
||||
|
||||
const actual = Buffer.from(payload.proof, 'hex');
|
||||
const actual = Buffer.from(String(payload.proof), 'hex');
|
||||
const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId));
|
||||
if (!expectedProof) return false;
|
||||
const expected = Buffer.from(expectedProof, 'hex');
|
||||
@@ -105,7 +143,14 @@ export async function probeGatewayPresence({
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = 1000,
|
||||
nonce = crypto.randomBytes(16).toString('hex'),
|
||||
}) {
|
||||
}: {
|
||||
gateway: string;
|
||||
subscriptionUrl: unknown;
|
||||
port?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
nonce?: string;
|
||||
}): Promise<VerifiedGateway> {
|
||||
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
||||
|
||||
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
|
||||
@@ -113,26 +158,27 @@ export async function probeGatewayPresence({
|
||||
presenceUrl,
|
||||
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const payload = record(await response.json().catch(() => ({})));
|
||||
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
|
||||
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayId: payload.gatewayId,
|
||||
gatewayId: String(payload.gatewayId),
|
||||
uiOrigin: new URL(presenceUrl).origin,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHostNetworkState(value, {
|
||||
export function normalizeHostNetworkState(value: unknown, {
|
||||
now = Date.now(),
|
||||
maxAgeMs = 15_000,
|
||||
} = {}) {
|
||||
const gateway = String(value?.gateway || '').trim();
|
||||
const networkInterface = String(value?.interface || '').trim();
|
||||
const mac = String(value?.mac || '').trim().toLowerCase();
|
||||
const observedAt = Date.parse(value?.observedAt || '');
|
||||
}: { now?: number; maxAgeMs?: number } = {}): GatewayRoute | null {
|
||||
const candidate = record(value);
|
||||
const gateway = String(candidate.gateway || '').trim();
|
||||
const networkInterface = String(candidate.interface || '').trim();
|
||||
const mac = String(candidate.mac || '').trim().toLowerCase();
|
||||
const observedAt = Date.parse(String(candidate.observedAt || ''));
|
||||
|
||||
// ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it.
|
||||
if (
|
||||
@@ -147,7 +193,7 @@ export function normalizeHostNetworkState(value, {
|
||||
return { gateway, interface: networkInterface, mac, observedAt };
|
||||
}
|
||||
|
||||
export function readHostNetworkState(filePath, options) {
|
||||
export function readHostNetworkState(filePath: string, options?: { now?: number; maxAgeMs?: number }) {
|
||||
try {
|
||||
return normalizeHostNetworkState(
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
||||
@@ -158,7 +204,7 @@ export function readHostNetworkState(filePath, options) {
|
||||
}
|
||||
}
|
||||
|
||||
export function sameGatewayRoute(previous, current) {
|
||||
export function sameGatewayRoute(previous: GatewayRoute | null, current: GatewayRoute | null) {
|
||||
return Boolean(
|
||||
previous &&
|
||||
current &&
|
||||
@@ -168,7 +214,7 @@ export function sameGatewayRoute(previous, current) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createGatewayAutoState() {
|
||||
export function createGatewayAutoState(): GatewayAutoRuntimeState {
|
||||
return {
|
||||
mode: 'local-vpn',
|
||||
failures: 0,
|
||||
@@ -180,18 +226,22 @@ export function createGatewayAutoState() {
|
||||
};
|
||||
}
|
||||
|
||||
export function applyGatewayPreference(state, enabled) {
|
||||
export function applyGatewayPreference(state: GatewayAutoRuntimeState, enabled: boolean): GatewayAutoRuntimeState {
|
||||
return {
|
||||
...state,
|
||||
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||
};
|
||||
}
|
||||
|
||||
export function nextGatewayAutoState(current, {
|
||||
export function nextGatewayAutoState(current: GatewayAutoRuntimeState, {
|
||||
network,
|
||||
verifiedGateway = null,
|
||||
error = 'Gateway presence check failed',
|
||||
}) {
|
||||
}: {
|
||||
network: GatewayRoute | null;
|
||||
verifiedGateway?: VerifiedGateway | null;
|
||||
error?: unknown;
|
||||
}): GatewayAutoRuntimeState {
|
||||
if (!network) {
|
||||
if (!current.gatewayId) return createGatewayAutoState();
|
||||
return {
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const options = { encoding: 'utf8' };
|
||||
const options = { encoding: 'utf8' as const };
|
||||
|
||||
export function setGatewayInterception(enabled, chain, run = spawnSync) {
|
||||
export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = spawnSync) {
|
||||
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
|
||||
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
|
||||
import { normalizeHarborError } from '../../shared/errors.js';
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function redactLogDetails(value: unknown) {
|
||||
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||
}
|
||||
|
||||
export function sendError(res: ServerResponse, error: unknown) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
const correlationId = crypto.randomUUID();
|
||||
const technical = harborError.cause instanceof Error
|
||||
? harborError.cause.message
|
||||
: harborError.details || error;
|
||||
const technicalMessage = technical instanceof Error
|
||||
? technical.message
|
||||
: technical;
|
||||
console.error(
|
||||
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technicalMessage)}`,
|
||||
);
|
||||
return sendJson(res, harborError.status, {
|
||||
success: false,
|
||||
error: {
|
||||
code: harborError.code,
|
||||
message: harborError.message,
|
||||
retryable: harborError.retryable,
|
||||
correlationId,
|
||||
...(harborError.details ? { details: harborError.details } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { ConnectionService } from '../../features/connection/index.js';
|
||||
|
||||
interface ConnectionRuntimeRouteDependencies {
|
||||
connection: Pick<ConnectionService, 'stop' | 'restart'>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
sendState(res: ServerResponse, extra: { singboxRunning: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createConnectionRuntimeRoute(dependencies: ConnectionRuntimeRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||
await dependencies.withOperation('stop', () => dependencies.connection.stop());
|
||||
await dependencies.sendState(res, { singboxRunning: false });
|
||||
return true;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||
await dependencies.withOperation('start', () => dependencies.connection.restart());
|
||||
await dependencies.sendState(res, { singboxRunning: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/index.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface ConnectivityDiagnosticsRouteDependencies {
|
||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function createConnectivityDiagnosticsRoute(
|
||||
dependencies: ConnectivityDiagnosticsRouteDependencies,
|
||||
) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false;
|
||||
const { services = [], target = null } = await dependencies.readBody(req);
|
||||
const result = await dependencies.diagnostics.run(services, target);
|
||||
sendJson(res, 200, result);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface DeviceInventoryPort {
|
||||
snapshot(): unknown;
|
||||
refresh(): Promise<unknown>;
|
||||
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DeviceInventoryRouteDependencies {
|
||||
deviceInventory: DeviceInventoryPort | null;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
||||
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
||||
|
||||
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
|
||||
if (pathname === '/api/devices') {
|
||||
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
sendJson(res, 200, dependencies.deviceInventory.snapshot());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/api/devices/refresh') {
|
||||
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
sendJson(res, 200, await dependencies.deviceInventory.refresh());
|
||||
return true;
|
||||
}
|
||||
|
||||
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||
if (deviceMatch) {
|
||||
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
const { expectedRevision, ...patch } = await dependencies.readBody(req);
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
dependencies.deviceInventory.update(deviceMatch[1], patch, expectedRevision),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const policyMatch = pathname.match(DEVICE_POLICY_PATH);
|
||||
if (policyMatch) {
|
||||
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
const body = await dependencies.readBody(req);
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
await dependencies.deviceInventory.setPolicy(
|
||||
policyMatch[1],
|
||||
body.mode,
|
||||
body.expectedRevision,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { GatewayAutoService } from '../../features/routing/index.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface GatewayAutoRouteDependencies {
|
||||
appMode: string;
|
||||
gatewayAuto: Pick<GatewayAutoService, 'setEnabled'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
readStatePayload(): Promise<Record<string, unknown> & { gatewayAuto?: unknown }>;
|
||||
}
|
||||
|
||||
export function createGatewayAutoRoute(dependencies: GatewayAutoRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/gateway-auto') return false;
|
||||
if (dependencies.appMode !== 'client') throw new HarborError('REQUEST_INVALID');
|
||||
const { enabled } = await dependencies.readBody(req);
|
||||
if (typeof enabled !== 'boolean') throw new HarborError('REQUEST_INVALID');
|
||||
|
||||
await dependencies.withOperation(
|
||||
'gateway-auto',
|
||||
() => dependencies.gatewayAuto.setEnabled(enabled),
|
||||
);
|
||||
const state = await dependencies.readStatePayload();
|
||||
sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { buildGatewayPresence } from '../../gatewayPresence.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface GatewayPresenceState {
|
||||
subscriptionUrl?: unknown;
|
||||
}
|
||||
|
||||
interface GatewayPresenceRouteDependencies {
|
||||
appMode: string;
|
||||
readState(): GatewayPresenceState;
|
||||
getHwid(): unknown;
|
||||
}
|
||||
|
||||
export function createGatewayPresenceRoute(dependencies: GatewayPresenceRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
||||
if (req.method !== 'GET' || requestUrl.pathname !== '/api/gateway-presence') return false;
|
||||
|
||||
const state = dependencies.readState();
|
||||
const gatewayId = dependencies.getHwid();
|
||||
sendJson(res, 200, buildGatewayPresence({
|
||||
appMode: dependencies.appMode,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
gatewayId,
|
||||
nonce: requestUrl.searchParams.get('nonce'),
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { sendPrometheusMetrics } from '../../prometheusMetrics.js';
|
||||
|
||||
interface MetricsSnapshotPort {
|
||||
metricsSnapshot(): unknown;
|
||||
}
|
||||
|
||||
interface PrometheusMetricsRouteDependencies {
|
||||
deviceInventory: MetricsSnapshotPort | null;
|
||||
}
|
||||
|
||||
export function createPrometheusMetricsRoute(dependencies: PrometheusMetricsRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (pathname !== '/metrics') return false;
|
||||
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
sendPrometheusMetrics(res, dependencies.deviceInventory.metricsSnapshot());
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { RouteRulesService } from '../../features/routing/index.js';
|
||||
|
||||
interface RouteRulesRouteDependencies {
|
||||
routeRules: RouteRulesService;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
sendState(res: ServerResponse): Promise<void>;
|
||||
}
|
||||
|
||||
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false;
|
||||
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req);
|
||||
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision);
|
||||
await dependencies.sendState(res);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { ConnectionService } from '../../features/connection/index.js';
|
||||
|
||||
interface ServerApplyRouteDependencies {
|
||||
connection: Pick<ConnectionService, 'apply'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
|
||||
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'apply-server',
|
||||
() => dependencies.connection.apply(serverId, selectedTag),
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { ServerHealthService } from '../../features/servers/index.js';
|
||||
|
||||
interface ServerHealthRouteDependencies {
|
||||
serverHealth: ServerHealthService;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
|
||||
const { serverIds = [] } = await dependencies.readBody(req);
|
||||
const results = await dependencies.serverHealth.check(serverIds);
|
||||
await dependencies.sendState(res, { results });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { buildSharedProxyInfo } from '../../sharedProxy.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface SharedProxyRouteDependencies {
|
||||
appMode: string;
|
||||
proxyPort: unknown;
|
||||
sharedProxyHost: unknown;
|
||||
refreshRuntime(): Promise<{ running?: unknown }>;
|
||||
}
|
||||
|
||||
export function createSharedProxyRoute(dependencies: SharedProxyRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'GET' || req.url !== '/api/shared-proxy') return false;
|
||||
|
||||
const runtime = await dependencies.refreshRuntime();
|
||||
sendJson(res, 200, buildSharedProxyInfo({
|
||||
appMode: dependencies.appMode,
|
||||
proxyPort: dependencies.proxyPort,
|
||||
running: runtime.running,
|
||||
hostHeader: req.headers.host,
|
||||
sharedProxyHost: dependencies.sharedProxyHost,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { normalizeStoredState, type GatewayAutoState, type StateSnapshot } from '../../../shared/contracts/state.js';
|
||||
import type { StateReadResult, StateService } from '../../features/state/stateService.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
export interface LegacyStatePayload extends StateSnapshot, Record<string, unknown> {
|
||||
port: number;
|
||||
proxyPort: number;
|
||||
configExists: boolean;
|
||||
singboxRunning: boolean;
|
||||
singboxStartedAt: string | null;
|
||||
subscriptionHost: string;
|
||||
hasSubscription: boolean;
|
||||
selectedTag: string;
|
||||
userInfo: Record<string, unknown>;
|
||||
fetchedAt: string | null;
|
||||
gatewayAuto: {
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
address: string;
|
||||
uiOrigin: string;
|
||||
interface: string;
|
||||
failures: number;
|
||||
lastError: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface StateRouteDependencies {
|
||||
stateService: StateService;
|
||||
port: number;
|
||||
proxyPort: number;
|
||||
}
|
||||
|
||||
function withStateV0Compatibility(
|
||||
{ snapshot, storedState, gatewayAuto, configExists }: StateReadResult,
|
||||
{ port, proxyPort }: Pick<StateRouteDependencies, 'port' | 'proxyPort'>,
|
||||
): LegacyStatePayload {
|
||||
const stored = normalizeStoredState(storedState);
|
||||
return {
|
||||
...snapshot,
|
||||
port,
|
||||
proxyPort,
|
||||
configExists,
|
||||
singboxRunning: snapshot.connection.process === 'running',
|
||||
singboxStartedAt: snapshot.connection.startedAt,
|
||||
subscriptionHost: snapshot.subscription.host,
|
||||
hasSubscription: snapshot.subscription.status === 'ready',
|
||||
selectedTag: stored.selectedTag,
|
||||
userInfo: snapshot.subscription.userInfo,
|
||||
fetchedAt: snapshot.subscription.fetchedAt,
|
||||
gatewayAuto: snapshot.mode === 'client'
|
||||
? legacyGatewayAuto(gatewayAuto, stored.gatewayAutoEnabled !== false)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyGatewayAuto(gatewayAuto: GatewayAutoState, enabled: boolean) {
|
||||
return {
|
||||
mode: gatewayAuto?.mode || 'local-vpn',
|
||||
enabled,
|
||||
available: Boolean(gatewayAuto?.gatewayId),
|
||||
address: gatewayAuto?.gateway?.gateway || '',
|
||||
uiOrigin: gatewayAuto?.uiOrigin || '',
|
||||
interface: gatewayAuto?.gateway?.interface || '',
|
||||
failures: Number(gatewayAuto?.failures) || 0,
|
||||
lastError: gatewayAuto?.lastError || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateRoute(dependencies: StateRouteDependencies) {
|
||||
const readPayload = async () => withStateV0Compatibility(
|
||||
await dependencies.stateService.read(),
|
||||
dependencies,
|
||||
);
|
||||
|
||||
return {
|
||||
readPayload,
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'GET' || req.url !== '/api/state') return false;
|
||||
sendJson(res, 200, await readPayload());
|
||||
return true;
|
||||
},
|
||||
async send(res: ServerResponse, extra: Record<string, unknown> = {}) {
|
||||
sendJson(res, 200, { success: true, ...extra, state: await readPayload() });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StateRoute = ReturnType<typeof createStateRoute>;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { SubscriptionService } from '../../features/subscription/index.js';
|
||||
|
||||
interface SubscriptionMutationRouteDependencies {
|
||||
subscriptionService: Pick<
|
||||
SubscriptionService,
|
||||
'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription'
|
||||
>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||
const { url = '' } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'subscription-import',
|
||||
() => dependencies.subscriptionService.importSubscription(String(url).trim()),
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||
const { success: _success, ...result } = await dependencies.withOperation(
|
||||
'subscription-refresh',
|
||||
() => dependencies.subscriptionService.refreshSavedSubscription(),
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
await dependencies.withOperation(
|
||||
'subscription-forget',
|
||||
() => dependencies.subscriptionService.resetSavedSubscription(),
|
||||
);
|
||||
await dependencies.sendState(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { ValidateSubscription } from '../../features/subscription/index.js';
|
||||
|
||||
interface SubscriptionValidationRouteDependencies {
|
||||
validateSubscription: ValidateSubscription;
|
||||
readBody: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
||||
sendState: (res: ServerResponse, extra: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubscriptionValidationRoute(dependencies: SubscriptionValidationRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/subscription/validate') return false;
|
||||
const { url = '' } = await dependencies.readBody(req);
|
||||
await dependencies.sendState(res, await dependencies.validateSubscription(url));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { buildGatewayVersionInfo } from '../../version.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface DataplaneVersionState {
|
||||
gatewayBackendVersion?: unknown;
|
||||
singBoxVersion?: unknown;
|
||||
}
|
||||
|
||||
interface VersionRouteDependencies {
|
||||
versionInfo: Record<string, unknown>;
|
||||
refreshDataplaneRuntime: (() => Promise<DataplaneVersionState>) | null;
|
||||
}
|
||||
|
||||
export function createVersionRoute(dependencies: VersionRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'GET' || req.url !== '/api/version') return false;
|
||||
|
||||
const payload = dependencies.refreshDataplaneRuntime
|
||||
? buildGatewayVersionInfo(
|
||||
dependencies.versionInfo,
|
||||
await dependencies.refreshDataplaneRuntime(),
|
||||
)
|
||||
: dependencies.versionInfo;
|
||||
sendJson(res, 200, payload);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,940 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { createDataplaneClient } from './dataplaneClient.js';
|
||||
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
||||
import { settings } from './config.js';
|
||||
import {
|
||||
applyGatewayPreference,
|
||||
buildGatewayPresence,
|
||||
createGatewayAutoState,
|
||||
nextGatewayAutoState,
|
||||
probeGatewayPresence,
|
||||
readHostNetworkState,
|
||||
sameGatewayRoute,
|
||||
} from './gatewayPresence.js';
|
||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||
import { tcpPing } from './ping.js';
|
||||
import { checkServerHealth } from './serverHealth.js';
|
||||
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||
import {
|
||||
buildGatewayConfig,
|
||||
removeSingboxConfig,
|
||||
restoreSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import {
|
||||
fetchSubscription,
|
||||
getHwid,
|
||||
normalizeSubscriptionConfig,
|
||||
selectRefreshedServer,
|
||||
} from './subscription.js';
|
||||
import {
|
||||
createStateSnapshot,
|
||||
normalizeStoredState,
|
||||
withStateV0Compatibility,
|
||||
} from '../shared/contracts/state.js';
|
||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||
import { normalizeRouteRules } from '../shared/routingRules.js';
|
||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||
import {
|
||||
createDeviceInventoryService,
|
||||
createVendorLookup,
|
||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
migrateDeviceInventoryState,
|
||||
} from './services/deviceInventoryService.js';
|
||||
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||
import { sendPrometheusMetrics } from './prometheusMetrics.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;
|
||||
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||
'SUBSCRIPTION_EXPIRED',
|
||||
'SUBSCRIPTION_DISABLED',
|
||||
'SUBSCRIPTION_REJECTED',
|
||||
]);
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
const stateStore = createStateStore(settings.statePath);
|
||||
const subscriptionCacheStore = createJsonStore({
|
||||
filePath: settings.subscriptionCachePath,
|
||||
defaultValue: null,
|
||||
});
|
||||
const deviceStore = createJsonStore({
|
||||
filePath: settings.deviceStatePath,
|
||||
defaultValue: {},
|
||||
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 readSubscriptionCache() {
|
||||
const cached = subscriptionCacheStore.read();
|
||||
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
||||
cacheRecoveryLogged = true;
|
||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||
}
|
||||
return cached?.config
|
||||
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
|
||||
: cached;
|
||||
}
|
||||
|
||||
const initialStoredState = stateStore.read();
|
||||
if (stateStore.migration) {
|
||||
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
||||
}
|
||||
if (stateStore.recovery) {
|
||||
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
||||
}
|
||||
|
||||
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
||||
const versionInfo = buildVersionInfo(settings.appMode);
|
||||
const singboxRuntime = remoteDataplane
|
||||
? createDataplaneClient(settings.dataplaneSocket)
|
||||
: createSingboxRuntime({
|
||||
configPath: settings.configPath,
|
||||
gateway: settings.appMode === 'gateway',
|
||||
tproxyChain: settings.tproxyChain,
|
||||
});
|
||||
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
|
||||
? () => singboxRuntime.observeDevices()
|
||||
: () => readNeighborSnapshot(),
|
||||
observeTraffic: remoteDataplane
|
||||
? () => singboxRuntime.observeTraffic()
|
||||
: null,
|
||||
observeDomainTraffic: remoteDataplane
|
||||
? () => singboxRuntime.observeDomainTraffic()
|
||||
: null,
|
||||
observePolicy: remoteDataplane
|
||||
? () => singboxRuntime.observeDevicePolicy()
|
||||
: () => localDevicePolicy.snapshot(),
|
||||
applyPolicies: remoteDataplane
|
||||
? (devices) => singboxRuntime.applyDevicePolicies(devices)
|
||||
: (devices) => localDevicePolicy.apply(devices),
|
||||
vendor: createVendorLookup(),
|
||||
})
|
||||
: null;
|
||||
const localConnectivityDiagnostics = !remoteDataplane
|
||||
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||
: null;
|
||||
let subscriptionRefreshPromise = null;
|
||||
let subscriptionRefreshTimer = null;
|
||||
let gatewayDiscoveryPromise = null;
|
||||
let gatewayDiscoveryTimer = null;
|
||||
let deviceDiscoveryTimer = null;
|
||||
let gatewayAutoState = createGatewayAutoState();
|
||||
let controlOperation = Promise.resolve();
|
||||
let operationState = stateStore.recovery ? {
|
||||
kind: 'storage-recovery',
|
||||
status: 'failed',
|
||||
startedAt: stateStore.recovery.recoveredAt,
|
||||
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
|
||||
} : { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
let revision = normalizeStoredState(initialStoredState).revision;
|
||||
|
||||
function updateStoredState(update) {
|
||||
return stateStore.update((stored) => {
|
||||
const current = normalizeStoredState(stored);
|
||||
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
|
||||
revision = Math.max(revision, current.revision) + 1;
|
||||
next.revision = revision;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function withOperation(kind, operation) {
|
||||
operationState = {
|
||||
kind,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
try {
|
||||
const result = await operation();
|
||||
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
updateStoredState((state) => state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
operationState = {
|
||||
...operationState,
|
||||
status: 'failed',
|
||||
error: harborError.message,
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeControl(operation) {
|
||||
const result = controlOperation.then(operation, operation);
|
||||
// The caller observes result; this settled tail only keeps the next operation runnable.
|
||||
controlOperation = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function redactLogDetails(value) {
|
||||
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||
}
|
||||
|
||||
function sendError(res, error) {
|
||||
const harborError = normalizeHarborError(error);
|
||||
const correlationId = crypto.randomUUID();
|
||||
const technical = harborError.cause?.message || harborError.details || error;
|
||||
console.error(
|
||||
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
|
||||
);
|
||||
return sendJson(res, harborError.status, {
|
||||
success: false,
|
||||
error: {
|
||||
code: harborError.code,
|
||||
message: harborError.message,
|
||||
retryable: harborError.retryable,
|
||||
correlationId,
|
||||
...(harborError.details ? { details: harborError.details } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
req.on('data', (chunk) => {
|
||||
if (tooLarge) return;
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
tooLarge = true;
|
||||
reject(new HarborError('REQUEST_INVALID'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (tooLarge) return;
|
||||
if (!chunks.length) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch (cause) {
|
||||
reject(new HarborError('REQUEST_INVALID', { cause }));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function subscriptionHost(url) {
|
||||
try {
|
||||
return `${new URL(url).host}/…`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildActiveConfig(
|
||||
subscriptionConfig,
|
||||
selectedServerId,
|
||||
routeRules = stateStore.read().routeRules,
|
||||
) {
|
||||
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
||||
routeRules,
|
||||
});
|
||||
}
|
||||
|
||||
const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
function resetSavedSubscription({ stopRuntime = true } = {}) {
|
||||
return serializeControl(async () => {
|
||||
if (stopRuntime) await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
subscriptionCacheStore.remove();
|
||||
updateStoredState((state) => ({ routeRules: state.routeRules }));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
}
|
||||
|
||||
async function publicState() {
|
||||
const runtime = await singboxRuntime.refresh();
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
|
||||
const configExists = fs.existsSync(settings.configPath);
|
||||
const snapshot = createStateSnapshot({
|
||||
storedState: state,
|
||||
runtime,
|
||||
gatewayAuto: gatewayAutoState,
|
||||
appMode: settings.appMode,
|
||||
configExists,
|
||||
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
||||
operation: operationState,
|
||||
});
|
||||
return withStateV0Compatibility(snapshot, {
|
||||
storedState: { ...state, gatewayAutoEnabled },
|
||||
gatewayAuto: gatewayAutoState,
|
||||
port: settings.port,
|
||||
proxyPort: settings.proxyPort,
|
||||
configExists,
|
||||
});
|
||||
}
|
||||
|
||||
function writeCurrentConfig() {
|
||||
const state = stateStore.read();
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedServerId || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
||||
const previousState = gatewayAutoState;
|
||||
const stateChanged = !isDeepStrictEqual(previousState, nextState);
|
||||
const modeChanged = previousState.mode !== nextState.mode;
|
||||
gatewayAutoState = nextState;
|
||||
if (!modeChanged) {
|
||||
if (stateChanged) updateStoredState((state) => state);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const wasRunning = singboxRuntime.running;
|
||||
try {
|
||||
const configured = writeCurrentConfig();
|
||||
if (reconfigure && configured && wasRunning) await startSingbox();
|
||||
} catch (error) {
|
||||
gatewayAutoState = previousState;
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stateChanged) updateStoredState((state) => state);
|
||||
|
||||
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
|
||||
console.log(`[control] client route: ${nextState.mode}${route}`);
|
||||
}
|
||||
|
||||
function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState);
|
||||
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
|
||||
|
||||
gatewayDiscoveryPromise = serializeControl(async () => {
|
||||
const state = stateStore.read();
|
||||
const network = state.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
|
||||
if (!network) {
|
||||
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||
const discoveredState = nextGatewayAutoState(gatewayAutoState, {
|
||||
network: null,
|
||||
error: discoveryError,
|
||||
});
|
||||
const nextState = applyGatewayPreference(
|
||||
state.subscriptionUrl
|
||||
? { ...discoveredState, lastError: discoveryError }
|
||||
: discoveredState,
|
||||
state.gatewayAutoEnabled !== false,
|
||||
);
|
||||
await applyGatewayAutoState(
|
||||
nextState,
|
||||
{ reconfigure },
|
||||
);
|
||||
return gatewayAutoState;
|
||||
}
|
||||
|
||||
if (
|
||||
gatewayAutoState.mode === 'gateway-direct' &&
|
||||
!sameGatewayRoute(gatewayAutoState.gateway, network)
|
||||
) {
|
||||
await applyGatewayAutoState(
|
||||
nextGatewayAutoState(gatewayAutoState, { network }),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedGateway = await probeGatewayPresence({
|
||||
gateway: network.gateway,
|
||||
port: settings.gatewayPresencePort,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
});
|
||||
const latestState = stateStore.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!sameGatewayRoute(network, latestNetwork)
|
||||
) {
|
||||
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
||||
return gatewayAutoState;
|
||||
}
|
||||
await applyGatewayAutoState(
|
||||
applyGatewayPreference(
|
||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
|
||||
latestState.gatewayAutoEnabled !== false,
|
||||
),
|
||||
{ reconfigure },
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error?.message || 'Gateway presence check failed';
|
||||
const latestState = stateStore.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!sameGatewayRoute(network, latestNetwork)
|
||||
) {
|
||||
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
||||
return gatewayAutoState;
|
||||
}
|
||||
if (gatewayAutoState.lastError !== reason) {
|
||||
console.warn(`[control] Gateway не используется: ${reason}`);
|
||||
}
|
||||
await applyGatewayAutoState(
|
||||
applyGatewayPreference(
|
||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
|
||||
latestState.gatewayAutoEnabled !== false,
|
||||
),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
return gatewayAutoState;
|
||||
}).finally(() => {
|
||||
gatewayDiscoveryPromise = null;
|
||||
});
|
||||
|
||||
return gatewayDiscoveryPromise;
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedServerId, { persist = true } = {}) {
|
||||
const cached = readSubscriptionCache();
|
||||
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
|
||||
|
||||
if (persist) {
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
selectedServerId,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
}
|
||||
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
writeSingboxConfig(nextConfig);
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (error) {
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
throw error;
|
||||
}
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
...(persist ? {
|
||||
appliedServerId: selectedServerId,
|
||||
appliedAt: new Date().toISOString(),
|
||||
} : {}),
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
}
|
||||
|
||||
async function applyRouteRules(routeRules) {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedServerId || !cached?.config) {
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
routeRules,
|
||||
routeRulesRevision: current.routeRulesRevision + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||
try {
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
|
||||
if (wasRunning) await startSingbox();
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
routeRules,
|
||||
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||
routeRulesRevision: current.routeRulesRevision + 1,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
if (wasRunning) {
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (rollbackError) {
|
||||
throw new HarborError('PROCESS_START_FAILED', {
|
||||
cause: new AggregateError([error, rollbackError], 'Route rules rollback failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
|
||||
return serializeControl(async () => {
|
||||
const previousState = normalizeStoredState(stateStore.read());
|
||||
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedServerId = resetSelection
|
||||
? ''
|
||||
: selectRefreshedServer(
|
||||
previousState.selectedServerId,
|
||||
previousState.servers,
|
||||
parsed.servers,
|
||||
);
|
||||
const candidateConfig = selectedServerId
|
||||
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
|
||||
: null;
|
||||
const previousCache = readSubscriptionCache();
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const previousGatewayAutoState = gatewayAutoState;
|
||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||
|
||||
try {
|
||||
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
|
||||
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
||||
else removeSingboxConfig();
|
||||
subscriptionCacheStore.write({
|
||||
url: subscriptionUrl,
|
||||
config: parsed.sourceConfig || parsed.config,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
});
|
||||
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
||||
|
||||
updateStoredState((state) => ({
|
||||
...(resetSelection ? {
|
||||
routeRules: state.routeRules,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
connectionDesired: 'stopped',
|
||||
} : state),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedServerId,
|
||||
appliedServerId: selectedServerId,
|
||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
if (resetSelection) gatewayAutoState = createGatewayAutoState();
|
||||
} catch (error) {
|
||||
gatewayAutoState = previousGatewayAutoState;
|
||||
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
|
||||
else subscriptionCacheStore.remove();
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
if (wasRunning) {
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (rollbackError) {
|
||||
throw new HarborError('PROCESS_START_FAILED', {
|
||||
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedServerId,
|
||||
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function importSubscription(subscriptionUrl) {
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
|
||||
}
|
||||
|
||||
function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
try {
|
||||
const subscriptionUrl = stateStore.read().subscriptionUrl;
|
||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return await commitSubscription(subscriptionUrl, parsed);
|
||||
} catch (error) {
|
||||
if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
|
||||
await resetSavedSubscription();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
subscriptionRefreshPromise = null;
|
||||
});
|
||||
|
||||
return subscriptionRefreshPromise;
|
||||
}
|
||||
|
||||
async function sendState(res, extra = {}) {
|
||||
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
|
||||
}
|
||||
|
||||
async function handleApi(req, res) {
|
||||
if (req.method === 'GET' && req.url === '/api/state') {
|
||||
return sendJson(res, 200, await publicState());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/api/version') {
|
||||
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
|
||||
const runtime = await singboxRuntime.refresh();
|
||||
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
||||
return sendJson(res, 200, buildSharedProxyInfo({
|
||||
appMode: settings.appMode,
|
||||
proxyPort: settings.proxyPort,
|
||||
running: (await singboxRuntime.refresh()).running,
|
||||
hostHeader: req.headers.host,
|
||||
sharedProxyHost: settings.sharedProxyHost,
|
||||
}));
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
||||
if (requestUrl.pathname === '/api/devices') {
|
||||
if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
return sendJson(res, 200, deviceInventory.snapshot());
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/api/devices/refresh') {
|
||||
if (!deviceInventory || req.method !== 'POST') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
return sendJson(res, 200, await deviceInventory.refresh());
|
||||
}
|
||||
|
||||
const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/);
|
||||
if (deviceMatch && req.method === 'PUT') {
|
||||
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const body = await readBody(req);
|
||||
const { expectedRevision, ...patch } = body;
|
||||
return sendJson(res, 200, deviceInventory.update(deviceMatch[1], patch, expectedRevision));
|
||||
}
|
||||
|
||||
const devicePolicyMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/);
|
||||
if (devicePolicyMatch && req.method === 'PUT') {
|
||||
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const body = await readBody(req);
|
||||
return sendJson(res, 200, await deviceInventory.setPolicy(
|
||||
devicePolicyMatch[1],
|
||||
body.mode,
|
||||
body.expectedRevision,
|
||||
));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
|
||||
const state = stateStore.read();
|
||||
return sendJson(res, 200, buildGatewayPresence({
|
||||
appMode: settings.appMode,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
gatewayId: getHwid(),
|
||||
nonce: requestUrl.searchParams.get('nonce'),
|
||||
}));
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||
const state = stateStore.read();
|
||||
const { serverIds = [] } = await readBody(req);
|
||||
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||
const servers = requestedIds.size
|
||||
? (state.servers || []).filter((server) => requestedIds.has(server.id))
|
||||
: state.servers || [];
|
||||
const results = await checkServerHealth(servers, tcpPing);
|
||||
return sendState(res, { results });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
||||
const { services = [], target = null } = await readBody(req);
|
||||
const state = stateStore.read();
|
||||
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||
const selected = (state.servers || []).find((server) => server.id === appliedServerId);
|
||||
const result = remoteDataplane
|
||||
? await singboxRuntime.runConnectivityDiagnostics(services, target)
|
||||
: await localConnectivityDiagnostics.run({
|
||||
vpnAvailable: (await singboxRuntime.refresh()).running,
|
||||
services,
|
||||
target,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
...result,
|
||||
vpn: {
|
||||
...result.vpn,
|
||||
server: selected ? { id: selected.id, label: selected.label } : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||
const { url = '' } = await readBody(req);
|
||||
const normalizedUrl = String(url).trim();
|
||||
const parsed = await withOperation('subscription-import', async () => {
|
||||
return importSubscription(normalizedUrl);
|
||||
});
|
||||
return sendState(res, parsed);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
|
||||
const { url = '' } = await readBody(req);
|
||||
const parsed = await fetchSubscription(String(url).trim());
|
||||
return sendState(res, { servers: parsed.servers.length });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||
const { success, ...result } = await withOperation(
|
||||
'subscription-refresh',
|
||||
() => refreshSavedSubscription(),
|
||||
);
|
||||
return sendState(res, result);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
||||
if (settings.appMode !== 'client') {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const { enabled } = await readBody(req);
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
await withOperation('gateway-auto', () => serializeControl(async () => {
|
||||
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
gatewayAutoEnabled: enabled,
|
||||
}));
|
||||
}));
|
||||
const state = await publicState();
|
||||
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||
}
|
||||
|
||||
if (req.method === 'PUT' && req.url === '/api/route-rules') {
|
||||
const { rules, expectedRulesRevision, expectedRevision } = await readBody(req);
|
||||
let routeRules;
|
||||
try {
|
||||
routeRules = normalizeRouteRules(rules, { strict: true });
|
||||
} catch (cause) {
|
||||
throw new HarborError('REQUEST_INVALID', { cause });
|
||||
}
|
||||
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
||||
if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
await serializeControl(async () => {
|
||||
const current = normalizeStoredState(stateStore.read());
|
||||
const currentRevision = expectedRulesRevision == null
|
||||
? current.revision
|
||||
: current.routeRulesRevision;
|
||||
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||
await withOperation('route-rules', () => applyRouteRules(routeRules));
|
||||
});
|
||||
return sendState(res);
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
await withOperation('subscription-forget', () => resetSavedSubscription());
|
||||
return sendState(res);
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||
const { serverId = '', selectedTag = '' } = await readBody(req);
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const id = String(serverId).trim() || (() => {
|
||||
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
})();
|
||||
if (!id || !state.servers.some((server) => server.id === id)) {
|
||||
throw new HarborError('SERVER_NOT_FOUND');
|
||||
}
|
||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
|
||||
return sendState(res, {
|
||||
serverId: id,
|
||||
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||
await withOperation('stop', () => serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
|
||||
}));
|
||||
return sendState(res, { singboxRunning: false });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||
await withOperation('start', () => serializeControl(async () => {
|
||||
if (!fs.existsSync(settings.configPath)) {
|
||||
throw new HarborError('CONFIG_INVALID');
|
||||
}
|
||||
await singboxRuntime.restart();
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
appliedServerId: state.selectedServerId,
|
||||
connectionDesired: 'running',
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
}));
|
||||
return sendState(res, { singboxRunning: true });
|
||||
}
|
||||
|
||||
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
||||
}
|
||||
|
||||
const mime = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
function serveStatic(req, res) {
|
||||
const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
||||
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
const filePath = path.resolve(settings.distDir, requested);
|
||||
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
res.writeHead(403);
|
||||
return res.end('Forbidden');
|
||||
}
|
||||
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||
? filePath
|
||||
: path.join(settings.distDir, 'index.html');
|
||||
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
||||
fs.createReadStream(finalPath).pipe(res);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
||||
if (requestUrl.pathname === '/metrics') {
|
||||
if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
return sendPrometheusMetrics(res, deviceInventory.metricsSnapshot());
|
||||
}
|
||||
return requestUrl.pathname.startsWith('/api/')
|
||||
? await handleApi(req, res)
|
||||
: serveStatic(req, res);
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(subscriptionRefreshTimer);
|
||||
clearInterval(gatewayDiscoveryTimer);
|
||||
clearInterval(deviceDiscoveryTimer);
|
||||
await serializeControl(() => singboxRuntime.shutdown());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await refreshGatewayAutoMode({ reconfigure: false })
|
||||
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
|
||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||
try {
|
||||
writeCurrentConfig();
|
||||
} catch (error) {
|
||||
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
|
||||
await resetSavedSubscription({ stopRuntime: false });
|
||||
}
|
||||
}
|
||||
await startSingbox()
|
||||
.then(() => {
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules }));
|
||||
}
|
||||
})
|
||||
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||
|
||||
if (deviceInventory) {
|
||||
await deviceInventory.reconcilePolicies()
|
||||
.catch((error) => console.warn(`[control] device policy не применена: ${error.message}`));
|
||||
}
|
||||
|
||||
server.listen(settings.port, '0.0.0.0', () => {
|
||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||
});
|
||||
|
||||
subscriptionRefreshTimer = setInterval(() => {
|
||||
if (!stateStore.read().subscriptionUrl) return;
|
||||
refreshSavedSubscription()
|
||||
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
||||
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||
subscriptionRefreshTimer.unref();
|
||||
|
||||
gatewayDiscoveryTimer = setInterval(() => {
|
||||
refreshGatewayAutoMode()
|
||||
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
|
||||
}, GATEWAY_DISCOVERY_INTERVAL_MS);
|
||||
gatewayDiscoveryTimer.unref();
|
||||
|
||||
if (deviceInventory) {
|
||||
deviceInventory.refresh()
|
||||
.catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`));
|
||||
deviceDiscoveryTimer = setInterval(() => {
|
||||
deviceInventory.refresh()
|
||||
.catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`));
|
||||
}, DEVICE_DISCOVERY_INTERVAL_MS);
|
||||
deviceDiscoveryTimer.unref();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from 'node:path';
|
||||
|
||||
process.env.DIST_DIR ||= path.resolve('dist');
|
||||
|
||||
if (process.env.APP_COMPONENT === 'dataplane') {
|
||||
await import('./dataplane.js');
|
||||
} else {
|
||||
await import('./index.js');
|
||||
}
|
||||
@@ -6,13 +6,20 @@ import dns from "node:dns/promises";
|
||||
|
||||
const DEFAULT_TIMEOUT = 3000;
|
||||
|
||||
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||
export interface PingResult {
|
||||
ok: boolean;
|
||||
latency: number | null;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function tcpPing(host: string, port: number, timeout = DEFAULT_TIMEOUT): Promise<PingResult> {
|
||||
const start = Date.now();
|
||||
return new Promise((resolve) => {
|
||||
return new Promise<PingResult>((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let done = false;
|
||||
|
||||
const finish = (result) => {
|
||||
const finish = (result: PingResult) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
socket.removeAllListeners();
|
||||
@@ -27,19 +34,19 @@ export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||
socket.once("timeout", () =>
|
||||
finish({ ok: false, latency: null, error: "timeout" }),
|
||||
);
|
||||
socket.once("error", (err) =>
|
||||
socket.once("error", (err: NodeJS.ErrnoException) =>
|
||||
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||
);
|
||||
|
||||
try {
|
||||
socket.connect(port, host);
|
||||
} catch (err) {
|
||||
finish({ ok: false, latency: null, error: err.message });
|
||||
finish({ ok: false, latency: null, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveHost(host) {
|
||||
export async function resolveHost(host: string): Promise<string | null> {
|
||||
if (net.isIP(host)) return host;
|
||||
try {
|
||||
const result = await dns.lookup(host);
|
||||
@@ -1,41 +1,56 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
|
||||
const COUNTER_PATTERN = /^\d+$/;
|
||||
|
||||
const labelValue = (value) => String(value ?? '')
|
||||
const labelValue = (value: unknown) => String(value ?? '')
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('\n', '\\n')
|
||||
.replaceAll('"', '\\"');
|
||||
|
||||
const labels = (values) => Object.entries(values)
|
||||
const labels = (values: Record<string, unknown>) => Object.entries(values)
|
||||
.map(([key, value]) => `${key}="${labelValue(value)}"`)
|
||||
.join(',');
|
||||
|
||||
function counter(value) {
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function counter(value: unknown) {
|
||||
const decimal = String(value ?? '');
|
||||
if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`);
|
||||
return decimal;
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
const milliseconds = Date.parse(value);
|
||||
function timestamp(value: unknown) {
|
||||
const milliseconds = Date.parse(String(value ?? ''));
|
||||
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||
}
|
||||
|
||||
function metric(lines, name, metricLabels, value) {
|
||||
function metric(
|
||||
lines: string[],
|
||||
name: string,
|
||||
metricLabels: Record<string, unknown>,
|
||||
value: unknown,
|
||||
) {
|
||||
lines.push(`${name}{${labels(metricLabels)}} ${value}`);
|
||||
}
|
||||
|
||||
export function renderPrometheusMetrics(snapshot) {
|
||||
export function renderPrometheusMetrics(value: unknown) {
|
||||
const snapshot = record(value);
|
||||
const traffic = record(snapshot.traffic);
|
||||
const lines = [
|
||||
'# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.',
|
||||
'# TYPE harbor_traffic_bytes_total counter',
|
||||
];
|
||||
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(snapshot?.traffic?.gatewayBytes));
|
||||
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(snapshot?.traffic?.proxyBytes));
|
||||
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(traffic.gatewayBytes));
|
||||
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(traffic.proxyBytes));
|
||||
|
||||
const globalFreshness = [
|
||||
['gateway', snapshot?.traffic?.gatewayObservedAt],
|
||||
['proxy', snapshot?.traffic?.proxyObservedAt],
|
||||
].map(([source, observedAt]) => [source, timestamp(observedAt)]).filter(([, observedAt]) => observedAt);
|
||||
['gateway', traffic.gatewayObservedAt],
|
||||
['proxy', traffic.proxyObservedAt],
|
||||
].map(([source, observedAt]) => [source, timestamp(observedAt)] as const).filter(([, observedAt]) => observedAt);
|
||||
if (globalFreshness.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful Harbor traffic observation.',
|
||||
@@ -46,7 +61,7 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
const devices = Array.isArray(snapshot?.devices) ? snapshot.devices : [];
|
||||
const devices = Array.isArray(snapshot.devices) ? snapshot.devices.map(record) : [];
|
||||
if (devices.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_device_info Current Harbor device identity metadata.',
|
||||
@@ -63,18 +78,18 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
|
||||
const deviceTraffic = devices.flatMap((device) => [
|
||||
timestamp(device.trafficObservedAt)
|
||||
? [device, 'gateway', timestamp(device.trafficObservedAt), device.uploadBytes, device.downloadBytes]
|
||||
? { device, source: 'gateway', observedAt: timestamp(device.trafficObservedAt), uploadBytes: device.uploadBytes, downloadBytes: device.downloadBytes }
|
||||
: null,
|
||||
timestamp(device.proxyTrafficObservedAt)
|
||||
? [device, 'proxy', timestamp(device.proxyTrafficObservedAt), device.proxyUploadBytes, device.proxyDownloadBytes]
|
||||
? { device, source: 'proxy', observedAt: timestamp(device.proxyTrafficObservedAt), uploadBytes: device.proxyUploadBytes, downloadBytes: device.proxyDownloadBytes }
|
||||
: null,
|
||||
].filter(Boolean));
|
||||
].filter((entry): entry is NonNullable<typeof entry> => entry !== null));
|
||||
if (deviceTraffic.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.',
|
||||
'# TYPE harbor_device_traffic_bytes_total counter',
|
||||
);
|
||||
for (const [device, source, , uploadBytes, downloadBytes] of deviceTraffic) {
|
||||
for (const { device, source, uploadBytes, downloadBytes } of deviceTraffic) {
|
||||
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||
device_id: device.id,
|
||||
source,
|
||||
@@ -91,7 +106,7 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
'# HELP harbor_device_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful device traffic observation.',
|
||||
'# TYPE harbor_device_traffic_last_observed_timestamp_seconds gauge',
|
||||
);
|
||||
for (const [device, source, observedAt] of deviceTraffic) {
|
||||
for (const { device, source, observedAt } of deviceTraffic) {
|
||||
metric(lines, 'harbor_device_traffic_last_observed_timestamp_seconds', {
|
||||
device_id: device.id,
|
||||
source,
|
||||
@@ -99,8 +114,8 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
const domainTraffic = snapshot?.domainTraffic;
|
||||
const domainSeries = Array.isArray(domainTraffic?.series) ? domainTraffic.series : [];
|
||||
const domainTraffic = record(snapshot.domainTraffic);
|
||||
const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
|
||||
if (domainSeries.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_device_domain_traffic_bytes_total Traffic observed by sing-box for a device and domain.',
|
||||
@@ -121,7 +136,7 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const domainObservedAt = timestamp(domainTraffic?.observedAt);
|
||||
const domainObservedAt = timestamp(domainTraffic.observedAt);
|
||||
if (domainObservedAt) {
|
||||
lines.push(
|
||||
'# HELP harbor_domain_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box connection observation.',
|
||||
@@ -129,15 +144,15 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
);
|
||||
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
|
||||
}
|
||||
if (domainTraffic?.overflowConnections != null) {
|
||||
if (domainTraffic.overflowConnections != null) {
|
||||
lines.push(
|
||||
'# HELP harbor_domain_traffic_overflow_connections_total Connections aggregated after the domain series limit was reached.',
|
||||
'# TYPE harbor_domain_traffic_overflow_connections_total counter',
|
||||
);
|
||||
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
||||
}
|
||||
const attributionEvents = domainTraffic?.attributionEvents;
|
||||
if (attributionEvents) {
|
||||
const attributionEvents = record(domainTraffic.attributionEvents);
|
||||
if (domainTraffic.attributionEvents) {
|
||||
lines.push(
|
||||
'# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.',
|
||||
'# TYPE harbor_domain_traffic_attribution_events_total counter',
|
||||
@@ -155,7 +170,7 @@ export function renderPrometheusMetrics(snapshot) {
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
export function sendPrometheusMetrics(res, snapshot) {
|
||||
export function sendPrometheusMetrics(res: ServerResponse, snapshot: unknown) {
|
||||
const body = renderPrometheusMetrics(snapshot);
|
||||
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
||||
res.end(body);
|
||||
@@ -1,27 +0,0 @@
|
||||
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||
|
||||
export async function checkServerHealth(servers, ping, {
|
||||
maxCount = SERVER_HEALTH_MAX_COUNT,
|
||||
concurrency = SERVER_HEALTH_CONCURRENCY,
|
||||
} = {}) {
|
||||
const queue = servers.slice(0, maxCount);
|
||||
const results = new Array(queue.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < queue.length) {
|
||||
const index = nextIndex++;
|
||||
const server = queue[index];
|
||||
results[index] = {
|
||||
id: server.id,
|
||||
tag: server.label,
|
||||
...await ping(server.host, server.port),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
+168
-48
@@ -7,18 +7,100 @@ import {
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
} from '../../shared/connectivityDiagnostics.js';
|
||||
import type { ConnectivityPathResult } from '../../shared/connectivityDiagnostics.js';
|
||||
|
||||
type PathKind = 'direct' | 'vpn';
|
||||
|
||||
interface CurlExecution {
|
||||
exitCode: number | null;
|
||||
error: string;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
type CurlExecutor = (args: string[]) => Promise<CurlExecution>;
|
||||
type DnsLookup = typeof dnsLookup;
|
||||
|
||||
interface BaseProbe {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IpProbe extends BaseProbe {
|
||||
family: 4 | 6;
|
||||
address: (body: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface SiteProbe extends BaseProbe {
|
||||
follow?: boolean;
|
||||
resolve?: string;
|
||||
validationError?: string;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
body?: boolean;
|
||||
ipv4?: boolean;
|
||||
follow?: boolean;
|
||||
resolve?: string | null;
|
||||
}
|
||||
|
||||
interface RequestResult {
|
||||
ok: boolean;
|
||||
body: string;
|
||||
exitCode: number | null;
|
||||
httpStatus: number | null;
|
||||
latencyMs: number | null;
|
||||
totalMs: number | null;
|
||||
stage: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface IpProbeResult {
|
||||
source: string;
|
||||
label: string;
|
||||
family: 4 | 6;
|
||||
address: string | null;
|
||||
attempts: number;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SiteProbeResult {
|
||||
id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
httpStatus: number | null;
|
||||
latencyMs: number | null;
|
||||
totalMs: number | null;
|
||||
stage: string;
|
||||
error: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type DiagnosticTarget =
|
||||
| { kind: 'ip'; probe: IpProbe }
|
||||
| { kind: 'site'; probe: SiteProbe };
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
||||
|
||||
const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||
const IP_PROBES: IpProbe[] = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||
...probe,
|
||||
family: probe.family === 6 ? 6 : 4,
|
||||
address: probe.id === 'cloudflare'
|
||||
? (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
||||
? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
||||
: probe.id === 'yandex-internet'
|
||||
? (body) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
||||
: (body) => body.trim(),
|
||||
? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
||||
: (body: string) => body.trim(),
|
||||
}));
|
||||
const SITE_PROBES = CONNECTIVITY_SITES;
|
||||
const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES];
|
||||
const TARGET_SAMPLE_COUNT = 3;
|
||||
|
||||
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||
@@ -27,18 +109,18 @@ for (const [address, prefix] of [
|
||||
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
||||
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||
]) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
||||
] as Array<[string, number]>) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
||||
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
|
||||
for (const [address, prefix] of [
|
||||
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
|
||||
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
||||
]) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||
] as Array<[string, number]>) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||
|
||||
function runCurl(args) {
|
||||
function runCurl(args: string[]): Promise<CurlExecution> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
|
||||
exitCode: typeof error?.code === 'number' && Number.isInteger(error.code) ? error.code : error ? null : 0,
|
||||
error: error?.message || '',
|
||||
stderr: stderr || '',
|
||||
stdout: stdout || '',
|
||||
@@ -47,27 +129,27 @@ function runCurl(args) {
|
||||
});
|
||||
}
|
||||
|
||||
function stageFor(exitCode) {
|
||||
function stageFor(exitCode: number | null) {
|
||||
if (exitCode === 6) return 'dns';
|
||||
if (exitCode === 7) return 'tcp';
|
||||
if ([35, 51, 58, 60].includes(exitCode)) return 'tls';
|
||||
if (exitCode !== null && [35, 51, 58, 60].includes(exitCode)) return 'tls';
|
||||
if (exitCode === 28) return 'timeout';
|
||||
return 'request';
|
||||
}
|
||||
|
||||
function milliseconds(value) {
|
||||
function milliseconds(value: unknown) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
const numbers = values.filter(Number.isFinite);
|
||||
function average(values: Array<number | null>) {
|
||||
const numbers = values.filter((value): value is number => Number.isFinite(value));
|
||||
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
||||
}
|
||||
|
||||
function mostCommon(values) {
|
||||
const counts = new Map();
|
||||
let selected = null;
|
||||
function mostCommon<T>(values: T[]): T | null {
|
||||
const counts = new Map<T, number>();
|
||||
let selected: T | null = null;
|
||||
let selectedCount = 0;
|
||||
for (const value of values) {
|
||||
const count = (counts.get(value) || 0) + 1;
|
||||
@@ -80,12 +162,12 @@ function mostCommon(values) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function request(probe, path, proxyPort, execute, {
|
||||
async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, {
|
||||
body = false,
|
||||
ipv4 = false,
|
||||
follow = true,
|
||||
resolve = null,
|
||||
} = {}) {
|
||||
}: RequestOptions = {}): Promise<RequestResult> {
|
||||
const args = [
|
||||
'--silent',
|
||||
'--show-error',
|
||||
@@ -114,13 +196,15 @@ async function request(probe, path, proxyPort, execute, {
|
||||
const result = await execute(args);
|
||||
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
|
||||
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
|
||||
let meta = {};
|
||||
let meta: Record<string, unknown> = {};
|
||||
try {
|
||||
meta = JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}');
|
||||
meta = record(JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'));
|
||||
} catch {
|
||||
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
|
||||
}
|
||||
const exitCode = Number.isInteger(meta.exitcode) ? meta.exitcode : result.exitCode;
|
||||
const exitCode = typeof meta.exitcode === 'number' && Number.isInteger(meta.exitcode)
|
||||
? meta.exitcode
|
||||
: result.exitCode;
|
||||
const ok = exitCode === 0;
|
||||
return {
|
||||
ok,
|
||||
@@ -134,8 +218,14 @@ async function request(probe, path, proxyPort, execute, {
|
||||
};
|
||||
}
|
||||
|
||||
async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
const samples = [];
|
||||
async function ipProbe(
|
||||
probe: IpProbe,
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
sampleCount = 1,
|
||||
): Promise<IpProbeResult> {
|
||||
const samples: Array<RequestResult & { address: string | null }> = [];
|
||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
||||
const parsed = result.ok ? probe.address(result.body) : null;
|
||||
@@ -144,7 +234,7 @@ async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
|
||||
});
|
||||
}
|
||||
const address = mostCommon(samples.map((sample) => sample.address).filter(Boolean));
|
||||
const address = mostCommon(samples.map((sample) => sample.address).filter((value): value is string => Boolean(value)));
|
||||
const matching = samples.filter((sample) => sample.address === address);
|
||||
return {
|
||||
source: probe.id,
|
||||
@@ -157,13 +247,13 @@ async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
};
|
||||
}
|
||||
|
||||
async function publicIps(path, proxyPort, execute) {
|
||||
async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecutor) {
|
||||
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
|
||||
const ipv4 = probes.filter((probe) => probe.family === 4);
|
||||
const ipv6 = probes.find((probe) => probe.family === 6);
|
||||
return {
|
||||
ipv4: {
|
||||
addresses: [...new Set(ipv4.map((probe) => probe.address).filter(Boolean))],
|
||||
addresses: [...new Set(ipv4.map((probe) => probe.address).filter((value): value is string => Boolean(value)))],
|
||||
sources: ipv4,
|
||||
},
|
||||
ipv6: ipv6?.address || null,
|
||||
@@ -171,20 +261,21 @@ async function publicIps(path, proxyPort, execute) {
|
||||
};
|
||||
}
|
||||
|
||||
function isPublicAddress(address, family) {
|
||||
function isPublicAddress(address: string, family: number) {
|
||||
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
||||
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
||||
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
|
||||
}
|
||||
|
||||
async function prepareCustomProbes(services, lookup) {
|
||||
async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise<SiteProbe[]> {
|
||||
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
|
||||
return Promise.all(requested.map(async (service, index) => {
|
||||
const requestedId = String(service?.id || '');
|
||||
const value = record(service);
|
||||
const requestedId = String(value.id || '');
|
||||
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(String(service?.url || '').trim());
|
||||
parsed = new URL(String(value.url || '').trim());
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
|
||||
throw new Error('Разрешены только публичные HTTPS-адреса');
|
||||
}
|
||||
@@ -198,7 +289,7 @@ async function prepareCustomProbes(services, lookup) {
|
||||
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
|
||||
return {
|
||||
id,
|
||||
label: String(service?.label || '').trim().slice(0, 40) || hostname,
|
||||
label: String(value.label || '').trim().slice(0, 40) || hostname,
|
||||
url: parsed.href,
|
||||
follow: false,
|
||||
resolve: `${hostname}:443:${pinned}`,
|
||||
@@ -206,19 +297,28 @@ async function prepareCustomProbes(services, lookup) {
|
||||
} catch (error) {
|
||||
return {
|
||||
id,
|
||||
label: String(service?.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
||||
validationError: error.message || 'Некорректный адрес',
|
||||
label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
||||
url: '',
|
||||
validationError: error instanceof Error ? error.message : 'Некорректный адрес',
|
||||
};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function siteStatus(result) {
|
||||
function siteStatus(result: RequestResult) {
|
||||
if (!result.ok) return 'unavailable';
|
||||
return result.httpStatus >= 200 && result.httpStatus < 400 ? 'available' : 'responded';
|
||||
return result.httpStatus !== null && result.httpStatus >= 200 && result.httpStatus < 400
|
||||
? 'available'
|
||||
: 'responded';
|
||||
}
|
||||
|
||||
async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
async function siteProbe(
|
||||
probe: SiteProbe,
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
sampleCount = 1,
|
||||
): Promise<SiteProbeResult> {
|
||||
if (probe.validationError) return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
@@ -235,12 +335,13 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
if (sampleCount === 1 && !samples[0].ok) {
|
||||
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
const status = mostCommon(samples.map(siteStatus));
|
||||
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
|
||||
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
||||
const representative = matching.at(-1);
|
||||
const representative = matching.at(-1) || samples.at(-1);
|
||||
if (!representative) throw new Error('Diagnostic probe produced no samples');
|
||||
return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
@@ -254,7 +355,12 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
};
|
||||
}
|
||||
|
||||
async function probePath(path, proxyPort, execute, sites) {
|
||||
async function probePath(
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
sites: SiteProbe[],
|
||||
): Promise<ConnectivityPathResult> {
|
||||
const [ip, siteResults] = await Promise.all([
|
||||
publicIps(path, proxyPort, execute),
|
||||
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
||||
@@ -269,7 +375,7 @@ async function probePath(path, proxyPort, execute, sites) {
|
||||
};
|
||||
}
|
||||
|
||||
function unavailablePath() {
|
||||
function unavailablePath(): ConnectivityPathResult {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'vpn-off',
|
||||
@@ -281,7 +387,7 @@ function unavailablePath() {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTarget(targetId, sites) {
|
||||
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
|
||||
if (typeof targetId !== 'string') return null;
|
||||
if (targetId.startsWith('ip:')) {
|
||||
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
||||
@@ -294,7 +400,12 @@ function resolveTarget(targetId, sites) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function probeTarget(target, path, proxyPort, execute) {
|
||||
async function probeTarget(
|
||||
target: DiagnosticTarget,
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
): Promise<ConnectivityPathResult> {
|
||||
const ip = target.kind === 'ip'
|
||||
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||
: null;
|
||||
@@ -308,7 +419,7 @@ async function probeTarget(target, path, proxyPort, execute) {
|
||||
available: true,
|
||||
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
|
||||
ipv4: {
|
||||
addresses: ipv4Sources.map(({ address }) => address).filter(Boolean),
|
||||
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
|
||||
sources: ipv4Sources,
|
||||
},
|
||||
ipv6: ipv6Source?.address || null,
|
||||
@@ -324,10 +435,19 @@ export function createConnectivityDiagnosticsService({
|
||||
execute = runCurl,
|
||||
lookup = dnsLookup,
|
||||
now = () => new Date().toISOString(),
|
||||
}: {
|
||||
proxyPort: number;
|
||||
execute?: CurlExecutor;
|
||||
lookup?: DnsLookup;
|
||||
now?: () => string;
|
||||
}) {
|
||||
async function runOnce({ vpnAvailable, services = [], target: targetId = null }) {
|
||||
const requestedServices = targetId?.startsWith('site:custom-')
|
||||
? (Array.isArray(services) ? services : []).filter(({ id }) => `site:${id}` === targetId)
|
||||
async function runOnce({ vpnAvailable, services = [], target: targetId = null }: {
|
||||
vpnAvailable: boolean;
|
||||
services?: unknown;
|
||||
target?: unknown;
|
||||
}) {
|
||||
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||
: targetId ? [] : services;
|
||||
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||
+415
-158
@@ -5,6 +5,154 @@ import { HarborError } from '../../shared/errors.js';
|
||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
||||
|
||||
type DevicePolicyMode = 'vpn' | 'direct';
|
||||
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
|
||||
|
||||
interface CounterBaseline {
|
||||
epoch: string;
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}
|
||||
|
||||
interface TrafficTotal {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
observedAt?: string | null;
|
||||
}
|
||||
|
||||
interface CounterTotal {
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
interface GlobalTrafficSource {
|
||||
epoch: string | null;
|
||||
lastObservedAt: string | null;
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
baselinesByMac: Record<string, CounterBaseline>;
|
||||
rebaselineMacs: string[];
|
||||
}
|
||||
|
||||
interface ProxyTrafficState {
|
||||
schemaVersion: number;
|
||||
lastObservedAt: string | null;
|
||||
lastError: string | null;
|
||||
baselinesByMac: Record<string, CounterBaseline>;
|
||||
totalsByMac: Record<string, TrafficTotal>;
|
||||
rebaselineMacs: string[];
|
||||
}
|
||||
|
||||
interface DevicePolicyEntry {
|
||||
desired: DevicePolicyMode;
|
||||
applied: DevicePolicyMode;
|
||||
status: DevicePolicyStatus;
|
||||
appliedAt: string | null;
|
||||
error: string | null;
|
||||
operationId: string | null;
|
||||
}
|
||||
|
||||
interface DevicePolicyState {
|
||||
schemaVersion: number;
|
||||
defaultMode: DevicePolicyMode;
|
||||
dataplaneEpoch: string | null;
|
||||
generation: string | null;
|
||||
fingerprint: string | null;
|
||||
lastAppliedAt: string | null;
|
||||
lastError: string | null;
|
||||
byMac: Record<string, DevicePolicyEntry>;
|
||||
}
|
||||
|
||||
interface InventoryDevice {
|
||||
id: string;
|
||||
alias: string;
|
||||
pinned: boolean;
|
||||
hostname: string | null;
|
||||
manufacturer: string | null;
|
||||
mac: string;
|
||||
ip: string;
|
||||
interface: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
source: string;
|
||||
confidence: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface InventoryTrafficState {
|
||||
epoch: string | null;
|
||||
generation: string | null;
|
||||
lastObservedAt: string | null;
|
||||
lastError: string | null;
|
||||
baselinesByMac: Record<string, CounterBaseline>;
|
||||
totalsByMac: Record<string, TrafficTotal>;
|
||||
rebaselineMacs: string[];
|
||||
proxy: ProxyTrafficState;
|
||||
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface InventoryState {
|
||||
schemaVersion: number;
|
||||
revision: number;
|
||||
lastObservedAt: string | null;
|
||||
lastError: string | null;
|
||||
policy: DevicePolicyState;
|
||||
traffic: InventoryTrafficState;
|
||||
devices: InventoryDevice[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DirectDevice {
|
||||
id: string;
|
||||
ip: string;
|
||||
mac: string;
|
||||
interface: string;
|
||||
}
|
||||
|
||||
interface PolicyAck {
|
||||
epoch: string;
|
||||
generation: string;
|
||||
fingerprint: string;
|
||||
observedAt: string;
|
||||
appliedIds: string[];
|
||||
}
|
||||
|
||||
interface InventoryStore {
|
||||
read(): unknown;
|
||||
update(transform: (stored: unknown) => InventoryState): InventoryState;
|
||||
}
|
||||
|
||||
interface TrafficSample {
|
||||
observedAt: string | null | undefined;
|
||||
gatewayBytes: string;
|
||||
proxyBytes: string;
|
||||
}
|
||||
|
||||
interface TrafficCursor {
|
||||
signature: string;
|
||||
gateway: bigint;
|
||||
proxy: bigint;
|
||||
}
|
||||
|
||||
interface DeviceObservation {
|
||||
mac: string;
|
||||
ip: string;
|
||||
interface: string;
|
||||
active: boolean;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export const DEVICE_INVENTORY_SCHEMA_VERSION = 3;
|
||||
const ONLINE_MS = 2 * 60 * 1000;
|
||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -14,11 +162,11 @@ const COUNTER_PATTERN = /^\d+$/;
|
||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const POLICY_MODES = new Set(['vpn', 'direct']);
|
||||
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
|
||||
const POLICY_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
|
||||
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
|
||||
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
||||
|
||||
const DEFAULT_DEVICE_POLICY = Object.freeze({
|
||||
const DEFAULT_DEVICE_POLICY: Readonly<DevicePolicyEntry> = Object.freeze({
|
||||
desired: 'vpn',
|
||||
applied: 'vpn',
|
||||
status: 'applied',
|
||||
@@ -27,7 +175,7 @@ const DEFAULT_DEVICE_POLICY = Object.freeze({
|
||||
operationId: null,
|
||||
});
|
||||
|
||||
const DEFAULT_POLICY_STATE = {
|
||||
const DEFAULT_POLICY_STATE: DevicePolicyState = {
|
||||
schemaVersion: 1,
|
||||
defaultMode: 'vpn',
|
||||
dataplaneEpoch: null,
|
||||
@@ -38,7 +186,7 @@ const DEFAULT_POLICY_STATE = {
|
||||
byMac: {},
|
||||
};
|
||||
|
||||
const DEFAULT_PROXY_TRAFFIC = {
|
||||
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
|
||||
schemaVersion: 1,
|
||||
lastObservedAt: null,
|
||||
lastError: null,
|
||||
@@ -47,7 +195,7 @@ const DEFAULT_PROXY_TRAFFIC = {
|
||||
rebaselineMacs: [],
|
||||
};
|
||||
|
||||
const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
|
||||
const DEFAULT_GLOBAL_TRAFFIC_SOURCE: GlobalTrafficSource = {
|
||||
epoch: null,
|
||||
lastObservedAt: null,
|
||||
uploadBytes: '0',
|
||||
@@ -56,7 +204,7 @@ const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
|
||||
rebaselineMacs: [],
|
||||
};
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
const DEFAULT_STATE: InventoryState = {
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
lastObservedAt: null,
|
||||
@@ -79,23 +227,86 @@ const DEFAULT_STATE = {
|
||||
devices: [],
|
||||
};
|
||||
|
||||
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
|
||||
export const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
|
||||
const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
|
||||
const recordEntries = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||
? Object.entries(value)
|
||||
: [];
|
||||
const parseStoredCounter = (value) => {
|
||||
const normalizeMac = (value: unknown) => String(value || '').trim().toLowerCase();
|
||||
export const deviceId = (mac: string) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
|
||||
const isPrivateMac = (mac: string) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
|
||||
const recordEntries = (value: unknown): Array<[string, Record<string, unknown>]> => (
|
||||
Object.entries(record(value)).map(([key, entry]) => [key, record(entry)])
|
||||
);
|
||||
const parseStoredCounter = (value: unknown) => {
|
||||
const counter = String(value ?? '');
|
||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||
};
|
||||
|
||||
const sumStoredTotals = (totalsByMac, key) => recordEntries(totalsByMac)
|
||||
.reduce((total, [, value]) => total + BigInt(value?.[key] || '0'), 0n)
|
||||
const validTimestamp = (value: unknown): value is string => (
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value))
|
||||
);
|
||||
|
||||
function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
||||
const device = record(value);
|
||||
const mac = normalizeMac(device.mac);
|
||||
const ip = typeof device.ip === 'string' ? device.ip : '';
|
||||
const deviceInterface = typeof device.interface === 'string' ? device.interface : '';
|
||||
if (!MAC_PATTERN.test(mac) || !net.isIPv4(ip) || !isDeviceInterface(deviceInterface)
|
||||
|| !validTimestamp(device.firstSeenAt) || !validTimestamp(device.lastSeenAt)) {
|
||||
return null;
|
||||
}
|
||||
const confidence = ['high', 'medium', 'ambiguous'].includes(String(device.confidence))
|
||||
? String(device.confidence)
|
||||
: isPrivateMac(mac) ? 'medium' : 'high';
|
||||
return {
|
||||
...device,
|
||||
id: typeof device.id === 'string' && DEVICE_ID_PATTERN.test(device.id)
|
||||
? device.id
|
||||
: deviceId(mac),
|
||||
alias: typeof device.alias === 'string' ? device.alias : '',
|
||||
pinned: device.pinned === true,
|
||||
hostname: typeof device.hostname === 'string' ? device.hostname : null,
|
||||
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
|
||||
mac,
|
||||
ip,
|
||||
interface: deviceInterface,
|
||||
firstSeenAt: device.firstSeenAt,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
source: typeof device.source === 'string' && device.source ? device.source : 'neighbor',
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeviceObservation(value: unknown): DeviceObservation | null {
|
||||
const observation = record(value);
|
||||
const mac = normalizeMac(observation.mac);
|
||||
const ip = typeof observation.ip === 'string' ? observation.ip : '';
|
||||
const deviceInterface = typeof observation.interface === 'string' ? observation.interface : '';
|
||||
if (!MAC_PATTERN.test(mac) || !net.isIPv4(ip) || !isDeviceInterface(deviceInterface)
|
||||
|| typeof observation.active !== 'boolean' || !validTimestamp(observation.observedAt)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mac,
|
||||
ip,
|
||||
interface: deviceInterface,
|
||||
active: observation.active,
|
||||
observedAt: observation.observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const sumStoredTotals = (totalsByMac: unknown, key: string) => recordEntries(totalsByMac)
|
||||
.reduce((total, [, value]) => total + BigInt(String(value[key] || '0')), 0n)
|
||||
.toString();
|
||||
|
||||
function normalizeGlobalTrafficSource(value, fallback, version) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
function normalizeGlobalTrafficSource(
|
||||
value: unknown,
|
||||
fallback: {
|
||||
epoch: string | null;
|
||||
lastObservedAt: string | null;
|
||||
baselinesByMac: Record<string, CounterBaseline>;
|
||||
totalsByMac: Record<string, TrafficTotal>;
|
||||
rebaselineMacs: string[];
|
||||
},
|
||||
version: number,
|
||||
): GlobalTrafficSource {
|
||||
const source = record(value);
|
||||
const fallbackMacs = new Set([
|
||||
...Object.keys(fallback.baselinesByMac),
|
||||
...Object.keys(fallback.totalsByMac),
|
||||
@@ -111,9 +322,9 @@ function normalizeGlobalTrafficSource(value, fallback, version) {
|
||||
rebaselineMacs: [...fallback.rebaselineMacs],
|
||||
};
|
||||
}
|
||||
const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
|
||||
const rebaselineMacs = new Set<string>((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
|
||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||
const baselinesByMac = {};
|
||||
const baselinesByMac: Record<string, CounterBaseline> = {};
|
||||
let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object'
|
||||
|| Array.isArray(source.baselinesByMac);
|
||||
for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) {
|
||||
@@ -141,12 +352,12 @@ function normalizeGlobalTrafficSource(value, fallback, version) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProxyTraffic(value, devices) {
|
||||
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
||||
function normalizeProxyTraffic(value: unknown, devices: InventoryDevice[]): ProxyTrafficState {
|
||||
const proxy = record(value);
|
||||
if (typeof proxy.schemaVersion === 'number' && Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
||||
throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`);
|
||||
}
|
||||
const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
|
||||
const rebaselineMacs = new Set<string>((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
|
||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||
let recovered = value !== undefined && (
|
||||
proxy !== value || proxy.schemaVersion !== 1
|
||||
@@ -156,7 +367,7 @@ function normalizeProxyTraffic(value, devices) {
|
||||
if (recovered) {
|
||||
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
||||
}
|
||||
const baselinesByMac = {};
|
||||
const baselinesByMac: Record<string, CounterBaseline> = {};
|
||||
for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||
@@ -169,7 +380,7 @@ function normalizeProxyTraffic(value, devices) {
|
||||
}
|
||||
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||
}
|
||||
const totalsByMac = {};
|
||||
const totalsByMac: Record<string, TrafficTotal> = {};
|
||||
for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
||||
@@ -203,9 +414,9 @@ function normalizeProxyTraffic(value, devices) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePolicyState(value) {
|
||||
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const byMac = {};
|
||||
function normalizePolicyState(value: unknown): DevicePolicyState {
|
||||
const policy = record(value);
|
||||
const byMac: Record<string, DevicePolicyEntry> = {};
|
||||
let recovered = value !== undefined && (
|
||||
policy.schemaVersion !== 1
|
||||
|| policy.defaultMode !== 'vpn'
|
||||
@@ -223,9 +434,9 @@ function normalizePolicyState(value) {
|
||||
continue;
|
||||
}
|
||||
byMac[mac] = {
|
||||
desired: entry.desired,
|
||||
applied: entry.applied,
|
||||
status: entry.status,
|
||||
desired: entry.desired as DevicePolicyMode,
|
||||
applied: entry.applied as DevicePolicyMode,
|
||||
status: entry.status as DevicePolicyStatus,
|
||||
appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null,
|
||||
error: typeof entry.error === 'string' ? entry.error : null,
|
||||
operationId: typeof entry.operationId === 'string' ? entry.operationId : null,
|
||||
@@ -246,8 +457,8 @@ function normalizePolicyState(value) {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOuiVendors(text) {
|
||||
const vendors = new Map();
|
||||
export function parseOuiVendors(text: unknown) {
|
||||
const vendors = new Map<string, string>();
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i);
|
||||
if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim());
|
||||
@@ -256,8 +467,8 @@ export function parseOuiVendors(text) {
|
||||
}
|
||||
|
||||
export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
|
||||
let vendors;
|
||||
return (mac) => {
|
||||
let vendors: Map<string, string> | undefined;
|
||||
return (mac: string) => {
|
||||
if (!mac || isPrivateMac(mac)) return null;
|
||||
if (!vendors) {
|
||||
try {
|
||||
@@ -270,20 +481,22 @@ export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateDeviceInventoryState(value) {
|
||||
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
|
||||
export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
const state = record(value);
|
||||
const version = typeof state.schemaVersion === 'number' && Number.isSafeInteger(state.schemaVersion)
|
||||
? state.schemaVersion
|
||||
: 0;
|
||||
if (version < 0 || version > DEVICE_INVENTORY_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
|
||||
}
|
||||
const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic)
|
||||
? state.traffic
|
||||
: {};
|
||||
const traffic = record(state.traffic);
|
||||
const devices = Array.isArray(state.devices)
|
||||
? state.devices.filter((device) => isDeviceInterface(device?.interface))
|
||||
? state.devices
|
||||
.map(normalizeInventoryDevice)
|
||||
.filter((device): device is InventoryDevice => device !== null)
|
||||
: [];
|
||||
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
||||
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||
let recoveredTraffic = version >= 2 && (
|
||||
traffic !== state.traffic
|
||||
@@ -293,7 +506,7 @@ export function migrateDeviceInventoryState(value) {
|
||||
if (recoveredTraffic) {
|
||||
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
||||
}
|
||||
const baselinesByMac = {};
|
||||
const baselinesByMac: Record<string, CounterBaseline> = {};
|
||||
for (const [rawMac, baseline] of recordEntries(traffic.baselinesByMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||
@@ -306,7 +519,7 @@ export function migrateDeviceInventoryState(value) {
|
||||
}
|
||||
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||
}
|
||||
const totalsByMac = {};
|
||||
const totalsByMac: Record<string, TrafficTotal> = {};
|
||||
for (const [rawMac, total] of recordEntries(traffic.totalsByMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
||||
@@ -329,14 +542,14 @@ export function migrateDeviceInventoryState(value) {
|
||||
}
|
||||
}
|
||||
const global = {
|
||||
gateway: normalizeGlobalTrafficSource(traffic.global?.gateway, {
|
||||
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
|
||||
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
||||
lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null,
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs],
|
||||
}, version),
|
||||
proxy: normalizeGlobalTrafficSource(traffic.global?.proxy, {
|
||||
proxy: normalizeGlobalTrafficSource(record(traffic.global).proxy, {
|
||||
epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null,
|
||||
lastObservedAt: proxyTraffic.lastObservedAt,
|
||||
baselinesByMac: proxyTraffic.baselinesByMac,
|
||||
@@ -348,14 +561,14 @@ export function migrateDeviceInventoryState(value) {
|
||||
...DEFAULT_STATE,
|
||||
...state,
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||
policy: normalizePolicyState(state.policy),
|
||||
traffic: {
|
||||
...DEFAULT_STATE.traffic,
|
||||
...traffic,
|
||||
lastError: recoveredTraffic
|
||||
? 'Повреждённый traffic checkpoint восстановлен из корректных данных'
|
||||
: traffic.lastError || null,
|
||||
: typeof traffic.lastError === 'string' ? traffic.lastError : null,
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||
@@ -366,17 +579,23 @@ export function migrateDeviceInventoryState(value) {
|
||||
};
|
||||
}
|
||||
|
||||
function deviceStatus(lastSeenAt, now) {
|
||||
function deviceStatus(lastSeenAt: string, now: Date): 'online' | 'recent' | 'offline' {
|
||||
const age = now.getTime() - new Date(lastSeenAt).getTime();
|
||||
if (age <= ONLINE_MS) return 'online';
|
||||
if (age <= RECENT_MS) return 'recent';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
function accumulateGlobalTraffic(source, countersByMac, epoch, observedAt, label) {
|
||||
function accumulateGlobalTraffic(
|
||||
source: GlobalTrafficSource,
|
||||
countersByMac: Map<string, CounterTotal>,
|
||||
epoch: string,
|
||||
observedAt: string | null,
|
||||
label: string,
|
||||
): GlobalTrafficSource {
|
||||
const epochChanged = Boolean(source.epoch && source.epoch !== epoch);
|
||||
const baselinesByMac = epochChanged ? {} : { ...source.baselinesByMac };
|
||||
const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs);
|
||||
const baselinesByMac: Record<string, CounterBaseline> = epochChanged ? {} : { ...source.baselinesByMac };
|
||||
const rebaselineMacs = new Set<string>(epochChanged ? [] : source.rebaselineMacs);
|
||||
let uploadBytes = BigInt(source.uploadBytes);
|
||||
let downloadBytes = BigInt(source.downloadBytes);
|
||||
for (const [mac, processTotal] of countersByMac) {
|
||||
@@ -419,14 +638,23 @@ export function createDeviceInventoryService({
|
||||
applyPolicies = null,
|
||||
vendor = () => null,
|
||||
now = () => new Date(),
|
||||
}: {
|
||||
store: InventoryStore;
|
||||
observe: () => unknown | Promise<unknown>;
|
||||
observeTraffic?: (() => unknown | Promise<unknown>) | null;
|
||||
observeDomainTraffic?: (() => unknown | Promise<unknown>) | null;
|
||||
observePolicy?: (() => unknown | Promise<unknown>) | null;
|
||||
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
|
||||
vendor?: (mac: string) => string | null;
|
||||
now?: () => Date;
|
||||
}) {
|
||||
let refreshPromise = null;
|
||||
let policyQueue = Promise.resolve();
|
||||
const trafficHistoryByMac = new Map();
|
||||
const trafficCursorByMac = new Map();
|
||||
let globalTrafficHistory = [];
|
||||
let globalTrafficCursor = null;
|
||||
let domainTrafficSnapshot = {
|
||||
let refreshPromise: Promise<unknown> | null = null;
|
||||
let policyQueue: Promise<unknown> = Promise.resolve();
|
||||
const trafficHistoryByMac = new Map<string, TrafficSample[]>();
|
||||
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
||||
let globalTrafficHistory: TrafficSample[] = [];
|
||||
let globalTrafficCursor: TrafficCursor | null = null;
|
||||
let domainTrafficSnapshot: Record<string, unknown> = {
|
||||
epoch: null,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
@@ -439,7 +667,7 @@ export function createDeviceInventoryService({
|
||||
series: [],
|
||||
};
|
||||
|
||||
function captureTrafficHistory(state) {
|
||||
function captureTrafficHistory(state: InventoryState) {
|
||||
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
|
||||
for (const device of state.devices) {
|
||||
const traffic = state.traffic.totalsByMac[device.mac];
|
||||
@@ -481,23 +709,24 @@ export function createDeviceInventoryService({
|
||||
}
|
||||
}
|
||||
|
||||
function serializePolicy(action) {
|
||||
const result = policyQueue.then(action, action);
|
||||
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
|
||||
const result = policyQueue.then(() => action(), () => action());
|
||||
policyQueue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
function policyFor(state, mac) {
|
||||
function policyFor(state: InventoryState, mac: string): Readonly<DevicePolicyEntry> {
|
||||
return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY;
|
||||
}
|
||||
|
||||
function policyIdentity(device) {
|
||||
return Boolean(device) && device.confidence !== 'ambiguous'
|
||||
function policyIdentity(device: InventoryDevice | null | undefined) {
|
||||
if (!device) return false;
|
||||
return device.confidence !== 'ambiguous'
|
||||
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
|
||||
&& isDeviceInterface(device.interface);
|
||||
}
|
||||
|
||||
function directDevices(state) {
|
||||
function directDevices(state: InventoryState): DirectDevice[] {
|
||||
return state.devices
|
||||
.filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device))
|
||||
.map(({ id, ip, mac, interface: deviceInterface }) => ({
|
||||
@@ -508,26 +737,27 @@ export function createDeviceInventoryService({
|
||||
}));
|
||||
}
|
||||
|
||||
function validatePolicyAck(result, requested) {
|
||||
const appliedIds = Array.isArray(result?.appliedIds) ? result.appliedIds : [];
|
||||
function validatePolicyAck(result: unknown, requested: DirectDevice[]): PolicyAck {
|
||||
const value = record(result);
|
||||
const appliedIds = Array.isArray(value.appliedIds) ? value.appliedIds : [];
|
||||
const expectedIds = new Set(requested.map(({ id }) => id));
|
||||
if (typeof result?.epoch !== 'string' || !result.epoch
|
||||
|| typeof result.generation !== 'string' || !result.generation
|
||||
|| !FINGERPRINT_PATTERN.test(result.fingerprint)
|
||||
|| typeof result.observedAt !== 'string' || !result.observedAt
|
||||
|| result.fingerprint !== fingerprintDirectDevices(requested)
|
||||
if (typeof value.epoch !== 'string' || !value.epoch
|
||||
|| typeof value.generation !== 'string' || !value.generation
|
||||
|| typeof value.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(value.fingerprint)
|
||||
|| typeof value.observedAt !== 'string' || !value.observedAt
|
||||
|| value.fingerprint !== fingerprintDirectDevices(requested)
|
||||
|| appliedIds.length !== expectedIds.size
|
||||
|| new Set(appliedIds).size !== appliedIds.length
|
||||
|| appliedIds.some((id) => !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
|
||||
|| appliedIds.some((id) => typeof id !== 'string' || !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
|
||||
throw new Error('Dataplane вернул невалидный device policy acknowledgement');
|
||||
}
|
||||
return result;
|
||||
return value as unknown as PolicyAck;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const state = migrateDeviceInventoryState(store.read());
|
||||
const current = now();
|
||||
const rank = { online: 0, recent: 1, offline: 2 };
|
||||
const rank: Record<'online' | 'recent' | 'offline', number> = { online: 0, recent: 1, offline: 2 };
|
||||
const devices = state.devices.map((device) => {
|
||||
const traffic = state.traffic.totalsByMac[device.mac];
|
||||
const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac];
|
||||
@@ -601,24 +831,27 @@ export function createDeviceInventoryService({
|
||||
return { ...snapshot(), domainTraffic: domainTrafficSnapshot };
|
||||
}
|
||||
|
||||
function markPolicyEpoch(observed) {
|
||||
if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return;
|
||||
function markPolicyEpoch(observed: unknown) {
|
||||
const value = record(observed);
|
||||
if (typeof value.epoch !== 'string' || !value.epoch || !Array.isArray(value.appliedIds)) return;
|
||||
const epoch = value.epoch;
|
||||
const acknowledgedIds = value.appliedIds;
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state;
|
||||
const appliedIds = new Set(observed.appliedIds);
|
||||
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === epoch) return state;
|
||||
const appliedIds = new Set(acknowledgedIds);
|
||||
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
const byMac = {};
|
||||
const byMac: Record<string, DevicePolicyEntry> = {};
|
||||
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
||||
const device = devicesByMac.get(mac);
|
||||
if (!device) continue;
|
||||
const applied = appliedIds.has(device.id) ? 'direct' : 'vpn';
|
||||
const applied: DevicePolicyMode = appliedIds.has(device.id) ? 'direct' : 'vpn';
|
||||
if (entry.desired === 'vpn' && applied === 'vpn') continue;
|
||||
byMac[mac] = {
|
||||
...entry,
|
||||
applied,
|
||||
status: entry.desired === applied ? 'applied' : 'pending',
|
||||
appliedAt: observed.observedAt || entry.appliedAt,
|
||||
appliedAt: typeof value.observedAt === 'string' ? value.observedAt : entry.appliedAt,
|
||||
error: entry.desired === applied
|
||||
? null
|
||||
: 'Dataplane перезапущен, маршрут ожидает повторного применения',
|
||||
@@ -630,10 +863,12 @@ export function createDeviceInventoryService({
|
||||
revision: state.revision + 1,
|
||||
policy: {
|
||||
...state.policy,
|
||||
dataplaneEpoch: observed.epoch,
|
||||
generation: typeof observed.generation === 'string' ? observed.generation : null,
|
||||
fingerprint: FINGERPRINT_PATTERN.test(observed.fingerprint) ? observed.fingerprint : null,
|
||||
lastAppliedAt: observed.observedAt || state.policy.lastAppliedAt,
|
||||
dataplaneEpoch: epoch,
|
||||
generation: typeof value.generation === 'string' ? value.generation : null,
|
||||
fingerprint: typeof value.fingerprint === 'string' && FINGERPRINT_PATTERN.test(value.fingerprint)
|
||||
? value.fingerprint
|
||||
: null,
|
||||
lastAppliedAt: typeof value.observedAt === 'string' ? value.observedAt : state.policy.lastAppliedAt,
|
||||
lastError: null,
|
||||
byMac,
|
||||
},
|
||||
@@ -641,12 +876,12 @@ export function createDeviceInventoryService({
|
||||
});
|
||||
}
|
||||
|
||||
function commitPolicySuccess(result) {
|
||||
function commitPolicySuccess(result: PolicyAck) {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []);
|
||||
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
const byMac = {};
|
||||
const byMac: Record<string, DevicePolicyEntry> = {};
|
||||
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
||||
const device = devicesByMac.get(mac);
|
||||
if (!device) continue;
|
||||
@@ -677,11 +912,11 @@ export function createDeviceInventoryService({
|
||||
});
|
||||
}
|
||||
|
||||
function commitPolicyFailure(error) {
|
||||
function commitPolicyFailure(error: unknown) {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const message = error.message || String(error);
|
||||
const byMac = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
|
||||
const message = errorMessage(error);
|
||||
const byMac: Record<string, DevicePolicyEntry> = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
|
||||
...entry,
|
||||
status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed',
|
||||
error: entry.status === 'applied' && entry.desired === entry.applied ? null : message,
|
||||
@@ -695,7 +930,7 @@ export function createDeviceInventoryService({
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcileLocked(observedPolicy, throwOnError) {
|
||||
async function reconcileLocked(observedPolicy: unknown, throwOnError: boolean) {
|
||||
if (!applyPolicies) return snapshot();
|
||||
markPolicyEpoch(observedPolicy);
|
||||
const state = migrateDeviceInventoryState(store.read());
|
||||
@@ -713,42 +948,44 @@ export function createDeviceInventoryService({
|
||||
|
||||
async function performRefresh() {
|
||||
const [result, trafficResult, policyResult, domainTrafficResult] = await Promise.all([
|
||||
Promise.resolve().then(() => observe()).catch((error) => ({
|
||||
Promise.resolve().then(() => observe()).catch((error: unknown) => ({
|
||||
observedAt: now().toISOString(),
|
||||
observations: [],
|
||||
error: error.message || String(error),
|
||||
})),
|
||||
error: errorMessage(error),
|
||||
})).then(record),
|
||||
observeTraffic
|
||||
? Promise.resolve().then(() => observeTraffic())
|
||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
|
||||
: null,
|
||||
observePolicy
|
||||
? Promise.resolve().then(() => observePolicy())
|
||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
|
||||
: null,
|
||||
observeDomainTraffic
|
||||
? Promise.resolve().then(() => observeDomainTraffic())
|
||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
|
||||
: null,
|
||||
]);
|
||||
const observedAt = result?.observedAt || now().toISOString();
|
||||
const observations = (Array.isArray(result?.observations) ? result.observations : [])
|
||||
.filter((observation) => isDeviceInterface(observation?.interface));
|
||||
const identitiesByMac = new Map();
|
||||
const observedAt = validTimestamp(result.observedAt) ? result.observedAt : now().toISOString();
|
||||
const observations = (Array.isArray(result.observations) ? result.observations : [])
|
||||
.map(normalizeDeviceObservation)
|
||||
.filter((observation): observation is DeviceObservation => observation !== null);
|
||||
const identitiesByMac = new Map<string, Set<string>>();
|
||||
for (const observation of observations) {
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
||||
if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set());
|
||||
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
|
||||
const identities = identitiesByMac.get(mac) || new Set<string>();
|
||||
identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
|
||||
identitiesByMac.set(mac, identities);
|
||||
}
|
||||
return serializePolicy(async () => {
|
||||
if (domainTrafficResult?.transportError) {
|
||||
if (typeof domainTrafficResult?.transportError === 'string') {
|
||||
domainTrafficSnapshot = {
|
||||
...domainTrafficSnapshot,
|
||||
source: { error: domainTrafficResult.transportError },
|
||||
};
|
||||
} else if (domainTrafficResult) {
|
||||
domainTrafficSnapshot = domainTrafficResult;
|
||||
domainTrafficSnapshot = record(domainTrafficResult);
|
||||
}
|
||||
const nextState = store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
@@ -757,8 +994,9 @@ export function createDeviceInventoryService({
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac) continue;
|
||||
const previous = byMac.get(mac);
|
||||
const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt;
|
||||
const lastSeenAt = observation.active || !previous
|
||||
? observation.observedAt || observedAt
|
||||
? observationTime
|
||||
: previous.lastSeenAt;
|
||||
byMac.set(mac, {
|
||||
id: previous?.id || deviceId(mac),
|
||||
@@ -769,10 +1007,10 @@ export function createDeviceInventoryService({
|
||||
mac,
|
||||
ip: String(observation.ip || previous?.ip || ''),
|
||||
interface: String(observation.interface || previous?.interface || ''),
|
||||
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
||||
firstSeenAt: previous?.firstSeenAt || observationTime,
|
||||
lastSeenAt,
|
||||
source: 'neighbor',
|
||||
confidence: identitiesByMac.get(mac)?.size > 1
|
||||
confidence: (identitiesByMac.get(mac)?.size || 0) > 1
|
||||
? 'ambiguous'
|
||||
: isPrivateMac(mac) ? 'medium' : 'high',
|
||||
});
|
||||
@@ -783,23 +1021,33 @@ export function createDeviceInventoryService({
|
||||
));
|
||||
let traffic = state.traffic;
|
||||
if (trafficResult) {
|
||||
if (trafficResult.transportError) {
|
||||
if (typeof trafficResult.transportError === 'string') {
|
||||
traffic = { ...traffic, lastError: trafficResult.transportError };
|
||||
} else {
|
||||
try {
|
||||
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
|
||||
throw new Error('Dataplane не вернул traffic epoch');
|
||||
}
|
||||
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : [];
|
||||
const processByMac = new Map();
|
||||
const proxyByMac = new Map();
|
||||
const trafficEpoch = trafficResult.epoch;
|
||||
const trafficObservedAt = typeof trafficResult.observedAt === 'string'
|
||||
? trafficResult.observedAt
|
||||
: null;
|
||||
const trafficGeneration = typeof trafficResult.generation === 'string'
|
||||
? trafficResult.generation
|
||||
: null;
|
||||
const trafficSourceError = typeof record(trafficResult.source).error === 'string'
|
||||
? String(record(trafficResult.source).error)
|
||||
: null;
|
||||
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices.map(record) : [];
|
||||
const processByMac = new Map<string, CounterTotal>();
|
||||
const proxyByMac = new Map<string, CounterTotal>();
|
||||
let proxyRows = 0;
|
||||
let legacyRows = 0;
|
||||
let proxySampleError = null;
|
||||
let proxySampleError: string | null = null;
|
||||
for (const row of rows) {
|
||||
const mac = normalizeMac(row?.mac);
|
||||
const upload = String(row?.uploadBytes ?? '');
|
||||
const download = String(row?.downloadBytes ?? '');
|
||||
const mac = normalizeMac(row.mac);
|
||||
const upload = String(row.uploadBytes ?? '');
|
||||
const download = String(row.downloadBytes ?? '');
|
||||
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(upload) || !COUNTER_PATTERN.test(download)) {
|
||||
throw new Error('Dataplane вернул невалидный traffic counter');
|
||||
}
|
||||
@@ -808,8 +1056,8 @@ export function createDeviceInventoryService({
|
||||
upload: previous.upload + BigInt(upload),
|
||||
download: previous.download + BigInt(download),
|
||||
});
|
||||
const hasProxyUpload = Object.hasOwn(row || {}, 'proxyUploadBytes');
|
||||
const hasProxyDownload = Object.hasOwn(row || {}, 'proxyDownloadBytes');
|
||||
const hasProxyUpload = Object.hasOwn(row, 'proxyUploadBytes');
|
||||
const hasProxyDownload = Object.hasOwn(row, 'proxyDownloadBytes');
|
||||
if (!hasProxyUpload && !hasProxyDownload) {
|
||||
legacyRows += 1;
|
||||
continue;
|
||||
@@ -840,7 +1088,7 @@ export function createDeviceInventoryService({
|
||||
if (!knownMacs.has(mac)) continue;
|
||||
const baseline = baselinesByMac[mac];
|
||||
const recovering = rebaselineMacs.has(mac);
|
||||
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
|
||||
const sameEpoch = !recovering && baseline?.epoch === trafficEpoch;
|
||||
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||
@@ -852,10 +1100,10 @@ export function createDeviceInventoryService({
|
||||
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
||||
downloadBytes: (BigInt(total.downloadBytes)
|
||||
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
||||
observedAt: trafficResult.observedAt || traffic.lastObservedAt,
|
||||
observedAt: trafficObservedAt || traffic.lastObservedAt,
|
||||
};
|
||||
baselinesByMac[mac] = {
|
||||
epoch: trafficResult.epoch,
|
||||
epoch: trafficEpoch,
|
||||
uploadBytes: processTotal.upload.toString(),
|
||||
downloadBytes: processTotal.download.toString(),
|
||||
};
|
||||
@@ -864,8 +1112,8 @@ export function createDeviceInventoryService({
|
||||
const globalGateway = accumulateGlobalTraffic(
|
||||
traffic.global.gateway,
|
||||
processByMac,
|
||||
trafficResult.epoch,
|
||||
trafficResult.observedAt || traffic.lastObservedAt,
|
||||
trafficEpoch,
|
||||
trafficObservedAt || traffic.lastObservedAt,
|
||||
'Gateway',
|
||||
);
|
||||
for (const mac of Object.keys(totalsByMac)) {
|
||||
@@ -916,7 +1164,7 @@ export function createDeviceInventoryService({
|
||||
if (!knownMacs.has(mac)) continue;
|
||||
const baseline = nextProxyBaselines[mac];
|
||||
const recovering = nextProxyRebaseline.has(mac);
|
||||
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
|
||||
const sameEpoch = !recovering && baseline?.epoch === trafficEpoch;
|
||||
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||
@@ -928,10 +1176,10 @@ export function createDeviceInventoryService({
|
||||
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
||||
downloadBytes: (BigInt(total.downloadBytes)
|
||||
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
||||
observedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
||||
observedAt: trafficObservedAt || proxy.lastObservedAt,
|
||||
};
|
||||
nextProxyBaselines[mac] = {
|
||||
epoch: trafficResult.epoch,
|
||||
epoch: trafficEpoch,
|
||||
uploadBytes: processTotal.upload.toString(),
|
||||
downloadBytes: processTotal.download.toString(),
|
||||
};
|
||||
@@ -940,14 +1188,14 @@ export function createDeviceInventoryService({
|
||||
const nextGlobalProxy = accumulateGlobalTraffic(
|
||||
traffic.global.proxy,
|
||||
proxyByMac,
|
||||
trafficResult.epoch,
|
||||
trafficResult.observedAt || proxy.lastObservedAt,
|
||||
trafficEpoch,
|
||||
trafficObservedAt || proxy.lastObservedAt,
|
||||
'proxy',
|
||||
);
|
||||
proxy = {
|
||||
...proxy,
|
||||
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
||||
lastError: trafficResult.source?.error
|
||||
lastObservedAt: trafficObservedAt || proxy.lastObservedAt,
|
||||
lastError: trafficSourceError
|
||||
|| (nextProxyRebaseline.size ? proxy.lastError : null),
|
||||
baselinesByMac: nextProxyBaselines,
|
||||
totalsByMac: nextProxyTotals,
|
||||
@@ -955,15 +1203,15 @@ export function createDeviceInventoryService({
|
||||
};
|
||||
globalProxy = nextGlobalProxy;
|
||||
} catch (error) {
|
||||
proxy = { ...proxy, lastError: error.message || String(error) };
|
||||
proxy = { ...proxy, lastError: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
traffic = {
|
||||
...traffic,
|
||||
epoch: trafficResult.epoch,
|
||||
generation: trafficResult.generation || traffic.generation,
|
||||
lastObservedAt: trafficResult.observedAt || traffic.lastObservedAt,
|
||||
lastError: trafficResult.source?.error
|
||||
epoch: trafficEpoch,
|
||||
generation: trafficGeneration || traffic.generation,
|
||||
lastObservedAt: trafficObservedAt || traffic.lastObservedAt,
|
||||
lastError: trafficSourceError
|
||||
|| (rebaselineMacs.size ? traffic.lastError : null),
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
@@ -972,7 +1220,7 @@ export function createDeviceInventoryService({
|
||||
global: { gateway: globalGateway, proxy: globalProxy },
|
||||
};
|
||||
} catch (error) {
|
||||
traffic = { ...traffic, lastError: error.message || String(error) };
|
||||
traffic = { ...traffic, lastError: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -980,13 +1228,15 @@ export function createDeviceInventoryService({
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
lastObservedAt: observedAt,
|
||||
lastError: result?.error || null,
|
||||
lastError: typeof result.error === 'string' ? result.error : null,
|
||||
traffic,
|
||||
devices,
|
||||
};
|
||||
});
|
||||
captureTrafficHistory(nextState);
|
||||
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
|
||||
if (typeof policyResult?.transportError === 'string') {
|
||||
commitPolicyFailure(new Error(policyResult.transportError));
|
||||
}
|
||||
return reconcileLocked(policyResult, false);
|
||||
});
|
||||
}
|
||||
@@ -1000,52 +1250,59 @@ export function createDeviceInventoryService({
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function update(id, patch, expectedRevision) {
|
||||
function update(id: string, patch: unknown, expectedRevision: unknown) {
|
||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const aliasProvided = Object.hasOwn(patch, 'alias');
|
||||
const pinProvided = Object.hasOwn(patch, 'pinned');
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||
const value = record(patch);
|
||||
const aliasProvided = Object.hasOwn(value, 'alias');
|
||||
const pinProvided = Object.hasOwn(value, 'pinned');
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||
|| (!aliasProvided && !pinProvided)
|
||||
|| (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64))
|
||||
|| (pinProvided && typeof patch.pinned !== 'boolean')) {
|
||||
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|
||||
|| (pinProvided && typeof value.pinned !== 'boolean')) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const revision = expectedRevision;
|
||||
const alias = typeof value.alias === 'string' ? value.alias : '';
|
||||
const pinned = value.pinned === true;
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||
const index = state.devices.findIndex((device) => device.id === id);
|
||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
||||
const devices = [...state.devices];
|
||||
devices[index] = {
|
||||
...devices[index],
|
||||
...(aliasProvided ? { alias: patch.alias.trim() } : {}),
|
||||
...(pinProvided ? { pinned: patch.pinned } : {}),
|
||||
...(aliasProvided ? { alias: alias.trim() } : {}),
|
||||
...(pinProvided ? { pinned } : {}),
|
||||
};
|
||||
return { ...state, revision: state.revision + 1, devices };
|
||||
});
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setPolicy(id, mode, expectedRevision) {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||
function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const revision = expectedRevision;
|
||||
const desiredMode = mode as DevicePolicyMode;
|
||||
return serializePolicy(async () => {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||
const device = state.devices.find((candidate) => candidate.id === id);
|
||||
if (!device) throw new HarborError('DEVICE_NOT_FOUND');
|
||||
if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
|
||||
if (desiredMode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
|
||||
const current = policyFor(state, device.mac);
|
||||
if (current.desired === mode && current.status === 'applied') return state;
|
||||
const byMac = {
|
||||
if (current.desired === desiredMode && current.status === 'applied') return state;
|
||||
const byMac: Record<string, DevicePolicyEntry> = {
|
||||
...state.policy.byMac,
|
||||
[device.mac]: {
|
||||
...current,
|
||||
desired: mode,
|
||||
desired: desiredMode,
|
||||
status: 'applying',
|
||||
error: null,
|
||||
operationId: crypto.randomUUID(),
|
||||
+54
-18
@@ -1,33 +1,56 @@
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||
|
||||
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||
const COMMAND_OPTIONS = { encoding: 'utf8' as const, timeout: 2_000, killSignal: 'SIGKILL' as const };
|
||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
||||
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
||||
const MAX_DEVICES = 512;
|
||||
|
||||
const childChain = (chain, slot) => `${chain}_${slot}`;
|
||||
const fingerprint = (devices) => crypto.createHash('sha256')
|
||||
export interface DirectDevice {
|
||||
id: string;
|
||||
ip: string;
|
||||
mac: string;
|
||||
interface: string;
|
||||
}
|
||||
|
||||
interface PolicySnapshot {
|
||||
epoch: string;
|
||||
generation: string;
|
||||
fingerprint: string;
|
||||
observedAt: string;
|
||||
appliedIds: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||
const fingerprint = (devices: readonly DirectDevice[]) => crypto.createHash('sha256')
|
||||
.update(JSON.stringify(devices))
|
||||
.digest('hex');
|
||||
|
||||
function commandError(command, result) {
|
||||
function commandError(command: string, result: SpawnSyncReturns<string>) {
|
||||
return new Error(String(
|
||||
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
||||
).trim());
|
||||
}
|
||||
|
||||
export function normalizeDirectDevices(value) {
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function normalizeDirectDevices(value: unknown): DirectDevice[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_DEVICES) {
|
||||
throw new Error('Некорректный набор device policy');
|
||||
}
|
||||
const ids = new Set();
|
||||
const tuples = new Set();
|
||||
const devices = value.map((device) => {
|
||||
const ids = new Set<string>();
|
||||
const tuples = new Set<string>();
|
||||
const devices = value.map((value) => {
|
||||
const device = record(value);
|
||||
const normalized = {
|
||||
id: String(device?.id || ''),
|
||||
ip: String(device?.ip || ''),
|
||||
@@ -47,9 +70,15 @@ export function normalizeDirectDevices(value) {
|
||||
return devices.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export const fingerprintDirectDevices = (value) => fingerprint(normalizeDirectDevices(value));
|
||||
export const fingerprintDirectDevices = (value: unknown) => fingerprint(normalizeDirectDevices(value));
|
||||
|
||||
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }) {
|
||||
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }: {
|
||||
devices: readonly DirectDevice[];
|
||||
chain: string;
|
||||
slot: string;
|
||||
tproxyPort: number;
|
||||
tproxyMark: string;
|
||||
}) {
|
||||
const child = childChain(chain, slot);
|
||||
const rules = ['*mangle', `-F ${child}`];
|
||||
for (const device of devices) {
|
||||
@@ -71,6 +100,13 @@ export function createDevicePolicyService({
|
||||
run = spawnSync,
|
||||
now = () => new Date(),
|
||||
nextGeneration = () => crypto.randomUUID(),
|
||||
}: {
|
||||
chain: string;
|
||||
tproxyPort: number;
|
||||
tproxyMark: string;
|
||||
run?: typeof spawnSync;
|
||||
now?: () => Date;
|
||||
nextGeneration?: () => string;
|
||||
}) {
|
||||
if (!CHAIN_PATTERN.test(String(chain || ''))
|
||||
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
|
||||
@@ -78,19 +114,19 @@ export function createDevicePolicyService({
|
||||
throw new Error('Некорректная конфигурация device policy');
|
||||
}
|
||||
const epoch = nextGeneration();
|
||||
let activeSlot = 'A';
|
||||
let activeSlot: 'A' | 'B' = 'A';
|
||||
let activeSignature = JSON.stringify([]);
|
||||
let generation = epoch;
|
||||
let appliedDevices = [];
|
||||
let appliedDevices: DirectDevice[] = [];
|
||||
let observedAt = now().toISOString();
|
||||
let queue = Promise.resolve();
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function execute(command, args, input) {
|
||||
function execute(command: string, args: string[], input?: string) {
|
||||
const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input });
|
||||
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||
}
|
||||
|
||||
function snapshot(changed = false) {
|
||||
function snapshot(changed = false): PolicySnapshot {
|
||||
return {
|
||||
epoch,
|
||||
generation,
|
||||
@@ -101,7 +137,7 @@ export function createDevicePolicyService({
|
||||
};
|
||||
}
|
||||
|
||||
function performApply(value) {
|
||||
function performApply(value: unknown) {
|
||||
const devices = normalizeDirectDevices(value);
|
||||
const signature = JSON.stringify(devices);
|
||||
if (signature === activeSignature) return snapshot(false);
|
||||
@@ -124,7 +160,7 @@ export function createDevicePolicyService({
|
||||
return snapshot(true);
|
||||
}
|
||||
|
||||
function apply(devices) {
|
||||
function apply(devices: unknown): Promise<PolicySnapshot> {
|
||||
const result = queue.then(() => performApply(devices));
|
||||
queue = result.catch(() => {});
|
||||
return result;
|
||||
+140
-64
@@ -1,9 +1,51 @@
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { isDeviceInterface, type NeighborObservation } from '../adapters/neighbors.js';
|
||||
|
||||
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||
interface CommandOptions {
|
||||
encoding: BufferEncoding;
|
||||
timeout: number;
|
||||
killSignal: NodeJS.Signals;
|
||||
input?: string;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
interface TrafficDevice {
|
||||
ip: string;
|
||||
mac: string;
|
||||
interface: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download';
|
||||
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload';
|
||||
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes';
|
||||
type CounterValues = Record<CounterField, bigint>;
|
||||
|
||||
interface RetiredCounters {
|
||||
slot: 'A' | 'B';
|
||||
devices: TrafficDevice[];
|
||||
counters: Map<string, string>;
|
||||
}
|
||||
|
||||
interface TrafficSnapshot {
|
||||
epoch: string;
|
||||
generation: string;
|
||||
observedAt: string | null;
|
||||
source: { error: string | null };
|
||||
devices: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
type RunCommand = (command: string, args: string[], options?: CommandOptions) => Promise<CommandResult>;
|
||||
|
||||
const COMMAND_OPTIONS: CommandOptions = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
||||
const COUNTERS = [
|
||||
@@ -11,37 +53,38 @@ const COUNTERS = [
|
||||
['download', 'download', 'downloadBytes'],
|
||||
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
|
||||
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
|
||||
];
|
||||
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
|
||||
|
||||
const childChain = (chain, slot) => `${chain}_${slot}`;
|
||||
const proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`;
|
||||
const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto
|
||||
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||
const proxyChildChain = (chain: string, slot: string) => `${childChain(chain, slot)}_P`;
|
||||
const counterKey = ({ ip, mac, interface: deviceInterface }: Omit<TrafficDevice, 'key'>) => crypto
|
||||
.createHash('sha256')
|
||||
.update(`${ip}|${mac}|${deviceInterface}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
function commandError(command, result) {
|
||||
function commandError(command: string, result: CommandResult) {
|
||||
const cause = result.error instanceof Error ? result.error.message : result.error;
|
||||
return new Error(String(
|
||||
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
||||
result.stderr || result.stdout || cause || `${command} завершился с ошибкой`,
|
||||
).trim());
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = COMMAND_OPTIONS) {
|
||||
function runCommand(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
let child;
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch (error) {
|
||||
resolve({ status: null, stdout: '', stderr: '', error });
|
||||
return;
|
||||
}
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let timer;
|
||||
const finish = (result) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (result: Pick<CommandResult, 'status'> & { error?: unknown }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
@@ -51,8 +94,8 @@ function runCommand(command, args, options = COMMAND_OPTIONS) {
|
||||
...result,
|
||||
});
|
||||
};
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on('error', (error) => finish({ status: null, error }));
|
||||
child.on('close', (status) => finish({
|
||||
status,
|
||||
@@ -67,36 +110,43 @@ function runCommand(command, args, options = COMMAND_OPTIONS) {
|
||||
});
|
||||
}
|
||||
|
||||
function isIpv4Cidr(value) {
|
||||
function isIpv4Cidr(value: unknown) {
|
||||
const [address, prefix, extra] = String(value).split('/');
|
||||
const size = Number(prefix);
|
||||
return extra === undefined && net.isIPv4(address)
|
||||
&& Number.isInteger(size) && size >= 0 && size <= 32;
|
||||
}
|
||||
|
||||
const zeroCounters = () => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
||||
const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
||||
|
||||
export function selectTrafficDevices(observations) {
|
||||
const candidates = new Map();
|
||||
const ipsByMac = new Map();
|
||||
const locationsByIp = new Map();
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
for (const observation of Array.isArray(observations) ? observations : []) {
|
||||
const ip = String(observation?.ip || '');
|
||||
const mac = String(observation?.mac || '').toLowerCase();
|
||||
const deviceInterface = String(observation?.interface || '');
|
||||
export function selectTrafficDevices(observations: unknown): TrafficDevice[] {
|
||||
const candidates = new Map<string, Omit<TrafficDevice, 'key'>>();
|
||||
const ipsByMac = new Map<string, Set<string>>();
|
||||
const locationsByIp = new Map<string, Set<string>>();
|
||||
|
||||
for (const value of Array.isArray(observations) ? observations : []) {
|
||||
const observation = record(value);
|
||||
const ip = String(observation.ip || '');
|
||||
const mac = String(observation.mac || '').toLowerCase();
|
||||
const deviceInterface = String(observation.interface || '');
|
||||
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue;
|
||||
|
||||
const location = `${mac}|${deviceInterface}`;
|
||||
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
||||
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
||||
ipsByMac.get(mac).add(ip);
|
||||
ipsByMac.get(mac)?.add(ip);
|
||||
if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set());
|
||||
locationsByIp.get(ip).add(location);
|
||||
locationsByIp.get(ip)?.add(location);
|
||||
}
|
||||
|
||||
return [...candidates.values()]
|
||||
.filter(({ ip, mac }) => ipsByMac.get(mac).size === 1 && locationsByIp.get(ip).size === 1)
|
||||
.filter(({ ip, mac }) => ipsByMac.get(mac)?.size === 1 && locationsByIp.get(ip)?.size === 1)
|
||||
.map((device) => ({ ...device, key: counterKey(device) }))
|
||||
.sort((left, right) => (
|
||||
left.ip.localeCompare(right.ip)
|
||||
@@ -112,6 +162,13 @@ export function buildTrafficRestore({
|
||||
downloadChain,
|
||||
slot,
|
||||
proxyPort,
|
||||
}: {
|
||||
devices: readonly TrafficDevice[];
|
||||
bypassCidrs: readonly string[];
|
||||
uploadChain: string;
|
||||
downloadChain: string;
|
||||
slot: string;
|
||||
proxyPort: number;
|
||||
}) {
|
||||
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|
||||
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|
||||
@@ -160,12 +217,12 @@ export function buildTrafficRestore({
|
||||
return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
|
||||
}
|
||||
|
||||
export function parseTrafficCounters(text, chain) {
|
||||
export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
|
||||
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const linePattern = new RegExp(
|
||||
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
|
||||
);
|
||||
const counters = new Map();
|
||||
const counters = new Map<string, string>();
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const match = line.match(linePattern);
|
||||
if (!match) continue;
|
||||
@@ -183,17 +240,25 @@ export function createDeviceTrafficService({
|
||||
proxyPort,
|
||||
run = runCommand,
|
||||
nextGeneration = () => crypto.randomUUID(),
|
||||
}: {
|
||||
observe: () => Promise<unknown> | unknown;
|
||||
uploadChain: string;
|
||||
downloadChain: string;
|
||||
bypassCidrs: string[];
|
||||
proxyPort: number;
|
||||
run?: RunCommand;
|
||||
nextGeneration?: () => string;
|
||||
}) {
|
||||
const epoch = nextGeneration();
|
||||
let activeSlot = null;
|
||||
let activeDevices = [];
|
||||
let activeSlot: 'A' | 'B' | null = null;
|
||||
let activeDevices: TrafficDevice[] = [];
|
||||
let activeSignature = '';
|
||||
let activeCounters = new Map();
|
||||
let pendingRetired = null;
|
||||
let refreshPromise = null;
|
||||
const finalized = new Map();
|
||||
const devicesByKey = new Map();
|
||||
let current = {
|
||||
let activeCounters = new Map<string, string>();
|
||||
let pendingRetired: RetiredCounters | null = null;
|
||||
let refreshPromise: Promise<TrafficSnapshot> | null = null;
|
||||
const finalized = new Map<string, CounterValues>();
|
||||
const devicesByKey = new Map<string, TrafficDevice>();
|
||||
let current: TrafficSnapshot = {
|
||||
epoch,
|
||||
generation: epoch,
|
||||
observedAt: null,
|
||||
@@ -201,13 +266,13 @@ export function createDeviceTrafficService({
|
||||
devices: [],
|
||||
};
|
||||
|
||||
async function execute(command, args, options = COMMAND_OPTIONS) {
|
||||
async function execute(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS) {
|
||||
const result = await run(command, args, options);
|
||||
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||
return String(result.stdout || '');
|
||||
}
|
||||
|
||||
async function prepare(slot, devices) {
|
||||
async function prepare(slot: 'A' | 'B', devices: TrafficDevice[]) {
|
||||
const input = buildTrafficRestore({
|
||||
devices,
|
||||
bypassCidrs,
|
||||
@@ -219,7 +284,7 @@ export function createDeviceTrafficService({
|
||||
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
|
||||
}
|
||||
|
||||
async function switchTo(slot) {
|
||||
async function switchTo(slot: 'A' | 'B') {
|
||||
const uploadChild = childChain(uploadChain, slot);
|
||||
const downloadChild = childChain(downloadChain, slot);
|
||||
const replace = activeSlot ? '-R' : '-A';
|
||||
@@ -242,8 +307,8 @@ export function createDeviceTrafficService({
|
||||
}
|
||||
}
|
||||
|
||||
async function readCounters(devices, slot) {
|
||||
if (!slot) return new Map();
|
||||
async function readCounters(devices: TrafficDevice[], slot: 'A' | 'B' | null): Promise<Map<string, string>> {
|
||||
if (!slot) return new Map<string, string>();
|
||||
const [raw, mangle] = await Promise.all([
|
||||
execute('iptables-save', ['-c', '-t', 'raw']),
|
||||
execute('iptables-save', ['-c', '-t', 'mangle']),
|
||||
@@ -254,7 +319,7 @@ export function createDeviceTrafficService({
|
||||
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
|
||||
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
|
||||
};
|
||||
const counters = new Map();
|
||||
const counters = new Map<string, string>();
|
||||
for (const { key } of devices) {
|
||||
for (const [kind, field] of COUNTERS) {
|
||||
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
|
||||
@@ -263,11 +328,11 @@ export function createDeviceTrafficService({
|
||||
return counters;
|
||||
}
|
||||
|
||||
function counter(counters, key, direction) {
|
||||
function counter(counters: Map<string, string>, key: string, direction: CounterKind) {
|
||||
return BigInt(counters.get(`${key}:${direction}`) || '0');
|
||||
}
|
||||
|
||||
function remember(devices) {
|
||||
function remember(devices: TrafficDevice[]) {
|
||||
for (const device of devices) devicesByKey.set(device.key, device);
|
||||
}
|
||||
|
||||
@@ -276,7 +341,7 @@ export function createDeviceTrafficService({
|
||||
const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
|
||||
for (const { key } of pendingRetired.devices) {
|
||||
const previous = finalized.get(key) || zeroCounters();
|
||||
const next = { ...previous };
|
||||
const next: CounterValues = { ...previous };
|
||||
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
|
||||
finalized.set(key, next);
|
||||
}
|
||||
@@ -286,12 +351,15 @@ export function createDeviceTrafficService({
|
||||
|
||||
function processTotals() {
|
||||
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
|
||||
const totalsByMac = new Map();
|
||||
const totalsByMac = new Map<string, TrafficDevice & CounterValues>();
|
||||
for (const [key, remembered] of devicesByKey) {
|
||||
const base = finalized.get(key) || zeroCounters();
|
||||
const pending = pendingRetired?.counters || new Map();
|
||||
const previous = totalsByMac.get(remembered.mac) || zeroCounters();
|
||||
const total = { ...(activeByMac.get(remembered.mac) || remembered) };
|
||||
const previous = totalsByMac.get(remembered.mac) || { ...remembered, ...zeroCounters() };
|
||||
const total: TrafficDevice & CounterValues = {
|
||||
...(activeByMac.get(remembered.mac) || remembered),
|
||||
...zeroCounters(),
|
||||
};
|
||||
for (const [kind, field] of COUNTERS) {
|
||||
total[field] = previous[field] + base[field]
|
||||
+ counter(pending, key, kind) + counter(activeCounters, key, kind);
|
||||
@@ -299,22 +367,28 @@ export function createDeviceTrafficService({
|
||||
totalsByMac.set(remembered.mac, total);
|
||||
}
|
||||
return [...totalsByMac.values()]
|
||||
.map((total) => Object.fromEntries([
|
||||
...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)),
|
||||
...COUNTERS.map(([, field, output]) => [output, total[field].toString()]),
|
||||
]))
|
||||
.map((total) => {
|
||||
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total;
|
||||
return {
|
||||
...device,
|
||||
uploadBytes: upload.toString(),
|
||||
downloadBytes: download.toString(),
|
||||
proxyUploadBytes: proxyUpload.toString(),
|
||||
proxyDownloadBytes: proxyDownload.toString(),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.mac.localeCompare(right.mac));
|
||||
}
|
||||
|
||||
async function performRefresh() {
|
||||
let observed;
|
||||
let observed: Record<string, unknown>;
|
||||
try {
|
||||
observed = await observe();
|
||||
observed = record(await observe());
|
||||
} catch (error) {
|
||||
observed = { observedAt: new Date().toISOString(), observations: [], error: error.message || String(error) };
|
||||
observed = { observedAt: new Date().toISOString(), observations: [], error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
let sourceError = observed?.error || null;
|
||||
let sourceError = observed.error ? String(observed.error) : null;
|
||||
const nextDevices = sourceError
|
||||
? activeDevices
|
||||
: selectTrafficDevices(observed?.observations);
|
||||
@@ -325,7 +399,7 @@ export function createDeviceTrafficService({
|
||||
try {
|
||||
countersRead = await finalizeRetired() || countersRead;
|
||||
} catch (error) {
|
||||
sourceError = sourceError || error.message || String(error);
|
||||
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +422,7 @@ export function createDeviceTrafficService({
|
||||
current.generation = nextGeneration();
|
||||
if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
|
||||
} catch (error) {
|
||||
sourceError = error.message || String(error);
|
||||
sourceError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,12 +430,14 @@ export function createDeviceTrafficService({
|
||||
activeCounters = await readCounters(activeDevices, activeSlot);
|
||||
countersRead = true;
|
||||
} catch (error) {
|
||||
sourceError = sourceError || error.message || String(error);
|
||||
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
current = {
|
||||
epoch,
|
||||
generation: current.generation,
|
||||
observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt,
|
||||
observedAt: countersRead && typeof observed.observedAt === 'string'
|
||||
? observed.observedAt
|
||||
: current.observedAt,
|
||||
source: { error: sourceError },
|
||||
devices: countersRead ? processTotals() : current.devices,
|
||||
};
|
||||
+99
-35
@@ -7,15 +7,67 @@ import { deviceId } from './deviceInventoryService.js';
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_MAX_SERIES = 4096;
|
||||
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
|
||||
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'];
|
||||
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
|
||||
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
|
||||
const SERVICE_DOMAINS = [
|
||||
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
||||
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
||||
];
|
||||
] as const;
|
||||
|
||||
const matchesDomain = (domain, suffix) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||
interface ParsedBaseConnection {
|
||||
id: string;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
export function classifyDomain(value) {
|
||||
type ParsedConnection =
|
||||
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
|
||||
| (ParsedBaseConnection & {
|
||||
outcome: 'classified' | 'unresolved_host';
|
||||
deviceId: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
source: 'gateway' | 'proxy';
|
||||
});
|
||||
|
||||
interface PreviousConnection {
|
||||
outcome: AttributionOutcome | 'classified';
|
||||
key?: string;
|
||||
requestedKey?: string;
|
||||
countedUpload: bigint | null;
|
||||
countedDownload: bigint | null;
|
||||
}
|
||||
|
||||
interface DomainSeriesTotal {
|
||||
deviceId: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
source: string;
|
||||
uploadBytes: bigint;
|
||||
downloadBytes: bigint;
|
||||
}
|
||||
|
||||
interface DomainTrafficSnapshot {
|
||||
epoch: string;
|
||||
observedAt: string | null;
|
||||
source: { error: string | null };
|
||||
overflowConnections: string;
|
||||
attributionEvents: Record<AttributionOutcome, string>;
|
||||
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||
|
||||
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
|
||||
let domain = domainToASCII(String(value || '').trim().replace(/\.$/, '')).toLowerCase();
|
||||
if (domain.startsWith('www.')) domain = domain.slice(4);
|
||||
const labels = domain.split('.');
|
||||
@@ -28,19 +80,20 @@ export function classifyDomain(value) {
|
||||
return { domain, service: domain };
|
||||
}
|
||||
|
||||
function sourceFor(type) {
|
||||
function sourceFor(type: string): 'gateway' | 'proxy' | null {
|
||||
if (type === 'tproxy/tproxy-in') return 'gateway';
|
||||
if (type === 'mixed/mixed-in') return 'proxy';
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseConnection(connection, devicesByIp) {
|
||||
const id = String(connection?.id || '');
|
||||
const metadata = connection?.metadata;
|
||||
const upload = connection?.upload;
|
||||
const download = connection?.download;
|
||||
if (!id || !Number.isSafeInteger(upload) || upload < 0
|
||||
|| !Number.isSafeInteger(download) || download < 0) {
|
||||
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
|
||||
const connection = record(value);
|
||||
const id = String(connection.id || '');
|
||||
const metadata = record(connection.metadata);
|
||||
const upload = connection.upload;
|
||||
const download = connection.download;
|
||||
if (!id || typeof upload !== 'number' || !Number.isSafeInteger(upload) || upload < 0
|
||||
|| typeof download !== 'number' || !Number.isSafeInteger(download) || download < 0) {
|
||||
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
||||
}
|
||||
const parsed = {
|
||||
@@ -48,11 +101,11 @@ function parseConnection(connection, devicesByIp) {
|
||||
upload: BigInt(upload),
|
||||
download: BigInt(download),
|
||||
};
|
||||
const source = sourceFor(String(metadata?.type || ''));
|
||||
const source = sourceFor(String(metadata.type || ''));
|
||||
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||
const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || ''));
|
||||
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
|
||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
||||
const classifiedDomain = classifyDomain(metadata?.host);
|
||||
const classifiedDomain = classifyDomain(metadata.host);
|
||||
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||
return {
|
||||
...parsed,
|
||||
@@ -63,13 +116,13 @@ function parseConnection(connection, devicesByIp) {
|
||||
};
|
||||
}
|
||||
|
||||
export function readSingboxConnections(port, timeoutMs = 1500) {
|
||||
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
|
||||
const chunks = [];
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
response.on('data', (chunk) => {
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
if (tooLarge) return;
|
||||
size += chunk.length;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
@@ -102,26 +155,35 @@ export function createDomainTrafficService({
|
||||
devices,
|
||||
now = () => new Date(),
|
||||
maxSeries = DEFAULT_MAX_SERIES,
|
||||
}: {
|
||||
observe: () => Promise<unknown> | unknown;
|
||||
devices: () => unknown;
|
||||
now?: () => Date;
|
||||
maxSeries?: number;
|
||||
}) {
|
||||
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
|
||||
const epoch = crypto.randomUUID();
|
||||
const totals = new Map();
|
||||
const totals = new Map<string, DomainSeriesTotal>();
|
||||
const normalSeriesLimit = maxSeries - 2;
|
||||
let normalSeries = 0;
|
||||
let previousConnections = new Map();
|
||||
let previousConnections = new Map<string, PreviousConnection>();
|
||||
let overflowConnections = 0n;
|
||||
const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n]));
|
||||
let refreshPromise = null;
|
||||
let current = {
|
||||
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||
unresolved_host: 0n,
|
||||
unknown_device: 0n,
|
||||
unsupported_source: 0n,
|
||||
};
|
||||
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
|
||||
let current: DomainTrafficSnapshot = {
|
||||
epoch,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
overflowConnections: '0',
|
||||
attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])),
|
||||
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||
series: [],
|
||||
};
|
||||
|
||||
function buildSnapshot(error = null) {
|
||||
function buildSnapshot(error: string | null = null): DomainTrafficSnapshot {
|
||||
return {
|
||||
epoch,
|
||||
observedAt: current.observedAt,
|
||||
@@ -129,7 +191,7 @@ export function createDomainTrafficService({
|
||||
overflowConnections: overflowConnections.toString(),
|
||||
attributionEvents: Object.fromEntries(
|
||||
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||
),
|
||||
) as Record<AttributionOutcome, string>,
|
||||
series: [...totals.values()]
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
@@ -147,17 +209,18 @@ export function createDomainTrafficService({
|
||||
|
||||
async function performRefresh() {
|
||||
try {
|
||||
const response = await observe();
|
||||
if (!Array.isArray(response?.connections)) throw new Error('Sing-box не вернул connections array');
|
||||
const devicesByIp = new Map();
|
||||
const response = record(await observe());
|
||||
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||
const devicesByIp = new Map<string, string | null>();
|
||||
const observedDevices = devices();
|
||||
for (const device of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||
const ip = String(device?.ip || '');
|
||||
const id = typeof device?.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||
const device = record(value);
|
||||
const ip = String(device.ip || '');
|
||||
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||
if (!net.isIPv4(ip) || !id) continue;
|
||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||
}
|
||||
const activeConnections = new Map();
|
||||
const activeConnections = new Map<string, PreviousConnection>();
|
||||
for (const rawConnection of response.connections) {
|
||||
const connection = parseConnection(rawConnection, devicesByIp);
|
||||
const previous = previousConnections.get(connection.id);
|
||||
@@ -172,8 +235,9 @@ export function createDomainTrafficService({
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||
let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey;
|
||||
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||
let domain = connection.domain;
|
||||
let service = connection.service;
|
||||
if (key !== requestedKey) {
|
||||
@@ -217,7 +281,7 @@ export function createDomainTrafficService({
|
||||
current = buildSnapshot();
|
||||
return current;
|
||||
} catch (error) {
|
||||
current = buildSnapshot(error.message || String(error));
|
||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { HarborError } from '../../shared/errors.js';
|
||||
|
||||
export interface RollbackStep {
|
||||
run(): unknown | Promise<unknown>;
|
||||
runtime?: boolean;
|
||||
}
|
||||
|
||||
export async function finishRollback(
|
||||
originalError: unknown,
|
||||
steps: RollbackStep[],
|
||||
message: string,
|
||||
): Promise<never> {
|
||||
const rollbackErrors: unknown[] = [];
|
||||
let runtimeRollbackFailed = false;
|
||||
for (const step of steps) {
|
||||
try {
|
||||
await step.run();
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError);
|
||||
runtimeRollbackFailed ||= Boolean(step.runtime);
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.length) {
|
||||
const cause = new AggregateError([originalError, ...rollbackErrors], message);
|
||||
if (runtimeRollbackFailed) throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||
throw cause;
|
||||
}
|
||||
throw originalError;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { normalizeStoredState } from '../../shared/contracts/state.js';
|
||||
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 4;
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
function syncDirectory(directory) {
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(directory, 'r');
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(error.code)) throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteFile(filePath, contents, { beforeRename, mode } = {}) {
|
||||
const directory = path.dirname(filePath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
|
||||
let descriptor;
|
||||
|
||||
try {
|
||||
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
|
||||
fs.writeFileSync(descriptor, contents, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
beforeRename?.(temporaryPath, filePath);
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
syncDirectory(directory);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteJson(filePath, value, options) {
|
||||
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
||||
}
|
||||
|
||||
export function migrateStoredState(value) {
|
||||
const stored = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const version = Number.isSafeInteger(stored.schemaVersion) ? stored.schemaVersion : 0;
|
||||
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
|
||||
}
|
||||
const routeRules = version < 3
|
||||
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
||||
: stored.routeRules;
|
||||
return {
|
||||
...normalizeStoredState({ ...stored, routeRules }),
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJsonStore({
|
||||
filePath,
|
||||
defaultValue,
|
||||
migrate = (value) => value,
|
||||
initializeMissing = false,
|
||||
backupWhen = () => false,
|
||||
now = () => new Date(),
|
||||
} = {}) {
|
||||
let recovery = null;
|
||||
let migration = null;
|
||||
|
||||
function write(value, options) {
|
||||
const migrated = migrate(clone(value));
|
||||
atomicWriteJson(filePath, migrated, options);
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const initial = migrate(clone(defaultValue));
|
||||
return initializeMissing ? write(initial) : clone(initial);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
|
||||
fs.renameSync(filePath, backupPath);
|
||||
try {
|
||||
const recovered = write(defaultValue);
|
||||
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
|
||||
return recovered;
|
||||
} catch (error) {
|
||||
fs.renameSync(backupPath, filePath);
|
||||
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const migrated = migrate(clone(parsed));
|
||||
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
|
||||
if (backupWhen(parsed, migrated)) {
|
||||
const fromVersion = Number.isSafeInteger(parsed?.schemaVersion) ? parsed.schemaVersion : 0;
|
||||
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
|
||||
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
|
||||
migration = {
|
||||
fromVersion,
|
||||
toVersion: migrated.schemaVersion,
|
||||
backupPath,
|
||||
migratedAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
atomicWriteJson(filePath, migrated);
|
||||
}
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function update(mutator) {
|
||||
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
|
||||
const next = mutator(read());
|
||||
if (next && typeof next.then === 'function') {
|
||||
throw new TypeError('State store mutator must be synchronous');
|
||||
}
|
||||
return write(next);
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
update,
|
||||
remove: () => fs.rmSync(filePath, { force: true }),
|
||||
get recovery() { return recovery; },
|
||||
get migration() { return migration; },
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateStore(filePath, options = {}) {
|
||||
return createJsonStore({
|
||||
filePath,
|
||||
defaultValue: {},
|
||||
migrate: migrateStoredState,
|
||||
initializeMissing: true,
|
||||
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
|
||||
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 4;
|
||||
|
||||
export interface AtomicWriteOptions {
|
||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
interface RecoveryState {
|
||||
kind: 'corrupt-json';
|
||||
backupPath: string;
|
||||
recoveredAt: string;
|
||||
}
|
||||
|
||||
interface MigrationState {
|
||||
fromVersion: number;
|
||||
toVersion: unknown;
|
||||
backupPath: string;
|
||||
migratedAt: string;
|
||||
}
|
||||
|
||||
interface JsonStoreBaseOptions {
|
||||
filePath: string;
|
||||
initializeMissing?: boolean;
|
||||
backupWhen?: (before: unknown, after: unknown) => boolean;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface JsonStoreOptions<T> extends JsonStoreBaseOptions {
|
||||
defaultValue: T;
|
||||
migrate: (value: unknown) => T;
|
||||
}
|
||||
|
||||
export interface RawJsonStoreOptions extends JsonStoreBaseOptions {
|
||||
defaultValue: unknown;
|
||||
migrate?: never;
|
||||
}
|
||||
|
||||
export interface JsonStore<T> {
|
||||
read(): T;
|
||||
write(value: T, options?: AtomicWriteOptions): T;
|
||||
update(mutator: (value: T) => T): T;
|
||||
remove(): void;
|
||||
readonly recovery: RecoveryState | null;
|
||||
readonly migration: MigrationState | null;
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string) {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(directory, 'r');
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(code)) throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteFile(
|
||||
filePath: string,
|
||||
contents: string | NodeJS.ArrayBufferView,
|
||||
{ beforeRename, mode }: AtomicWriteOptions = {},
|
||||
) {
|
||||
const directory = path.dirname(filePath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
|
||||
let descriptor: number | undefined;
|
||||
|
||||
try {
|
||||
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
|
||||
fs.writeFileSync(descriptor, contents, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
beforeRename?.(temporaryPath, filePath);
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
syncDirectory(directory);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function atomicWriteJson(filePath: string, value: unknown, options?: AtomicWriteOptions) {
|
||||
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
||||
}
|
||||
|
||||
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
|
||||
const stored = record(value);
|
||||
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
|
||||
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
|
||||
}
|
||||
const routeRules = version < 3
|
||||
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
||||
: stored.routeRules;
|
||||
return {
|
||||
...normalizeStoredState({ ...stored, routeRules }),
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
|
||||
export function createJsonStore(options: RawJsonStoreOptions): JsonStore<unknown>;
|
||||
export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStoreOptions): JsonStore<unknown> {
|
||||
const {
|
||||
filePath,
|
||||
defaultValue,
|
||||
initializeMissing = false,
|
||||
backupWhen = () => false,
|
||||
now = () => new Date(),
|
||||
} = options;
|
||||
const migrate = options.migrate || ((value: unknown) => value);
|
||||
let recovery: RecoveryState | null = null;
|
||||
let migration: MigrationState | null = null;
|
||||
|
||||
function write(value: unknown, writeOptions?: AtomicWriteOptions): unknown {
|
||||
const migrated = migrate(clone(value));
|
||||
atomicWriteJson(filePath, migrated, writeOptions);
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function read(): unknown {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const initial = migrate(clone(defaultValue));
|
||||
return initializeMissing ? write(initial) : clone(initial);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
|
||||
fs.renameSync(filePath, backupPath);
|
||||
try {
|
||||
const recovered = write(defaultValue);
|
||||
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
|
||||
return recovered;
|
||||
} catch (error) {
|
||||
fs.renameSync(backupPath, filePath);
|
||||
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const migrated = migrate(clone(parsed));
|
||||
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
|
||||
if (backupWhen(parsed, migrated)) {
|
||||
const parsedRecord = record(parsed);
|
||||
const fromVersion = Number.isSafeInteger(parsedRecord.schemaVersion) ? Number(parsedRecord.schemaVersion) : 0;
|
||||
const migratedRecord = record(migrated);
|
||||
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
|
||||
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
|
||||
migration = {
|
||||
fromVersion,
|
||||
toVersion: migratedRecord.schemaVersion,
|
||||
backupPath,
|
||||
migratedAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
atomicWriteJson(filePath, migrated);
|
||||
}
|
||||
return clone(migrated);
|
||||
}
|
||||
|
||||
function update(mutator: (value: unknown) => unknown): unknown {
|
||||
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
|
||||
const next = mutator(read());
|
||||
if (next && typeof next === 'object' && 'then' in next) {
|
||||
throw new TypeError('State store mutator must be synchronous');
|
||||
}
|
||||
return write(next);
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
update,
|
||||
remove: () => fs.rmSync(filePath, { force: true }),
|
||||
get recovery() { return recovery; },
|
||||
get migration() { return migration; },
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateStore(
|
||||
filePath: string,
|
||||
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
|
||||
) {
|
||||
return createJsonStore<StoredState & { schemaVersion: number }>({
|
||||
filePath,
|
||||
defaultValue: migrateStoredState({}),
|
||||
migrate: migrateStoredState,
|
||||
initializeMissing: true,
|
||||
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
function proxyHostFromHeader(hostHeader) {
|
||||
function proxyHostFromHeader(hostHeader: unknown) {
|
||||
const raw = String(hostHeader || "").trim();
|
||||
if (!raw) return "";
|
||||
if (raw.startsWith("[")) {
|
||||
@@ -14,9 +14,15 @@ export function buildSharedProxyInfo({
|
||||
running,
|
||||
hostHeader,
|
||||
sharedProxyHost,
|
||||
}: {
|
||||
appMode: unknown;
|
||||
proxyPort: unknown;
|
||||
running: unknown;
|
||||
hostHeader: unknown;
|
||||
sharedProxyHost: unknown;
|
||||
}) {
|
||||
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
||||
const port = Number.parseInt(proxyPort, 10);
|
||||
const port = Number.parseInt(String(proxyPort), 10);
|
||||
const available =
|
||||
appMode === "gateway" &&
|
||||
Boolean(running) &&
|
||||
@@ -11,20 +11,33 @@ const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
|
||||
const SNIFF_TIMEOUT = '1s';
|
||||
const SNIFFERS = ['http', 'tls', 'quic'];
|
||||
|
||||
function findOutbound(subscriptionConfig, selectedTag) {
|
||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||
? subscriptionConfig.outbounds
|
||||
interface ProxyOutbound extends Record<string, unknown> {
|
||||
tag?: string;
|
||||
type?: string;
|
||||
packet_encoding?: string;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function findOutbound(subscriptionConfig: unknown, selectedTag: unknown): ProxyOutbound | undefined {
|
||||
const config = record(subscriptionConfig);
|
||||
const outbounds = Array.isArray(config.outbounds)
|
||||
? config.outbounds.map(record)
|
||||
: [];
|
||||
const tag = String(selectedTag || '').trim();
|
||||
return outbounds.find((outbound) => (
|
||||
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
|
||||
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(String(outbound.type || ''))
|
||||
));
|
||||
}
|
||||
|
||||
export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
||||
export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unknown, {
|
||||
clientDirect = false,
|
||||
routeRules = [],
|
||||
} = {}) {
|
||||
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
|
||||
const clientMode = settings.appMode === 'client';
|
||||
const directClient = clientMode && clientDirect;
|
||||
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
@@ -109,11 +122,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
||||
};
|
||||
}
|
||||
|
||||
export function writeSingboxConfig(config) {
|
||||
export function writeSingboxConfig(config: unknown) {
|
||||
atomicWriteJson(settings.configPath, config);
|
||||
}
|
||||
|
||||
export function restoreSingboxConfig(contents) {
|
||||
export function restoreSingboxConfig(contents: string) {
|
||||
atomicWriteFile(settings.configPath, contents);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
import { setGatewayInterception } from './gatewayRouting.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
|
||||
export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) {
|
||||
let child = null;
|
||||
export function createSingboxRuntime({
|
||||
configPath,
|
||||
gateway = false,
|
||||
tproxyChain = '',
|
||||
}: {
|
||||
configPath: string;
|
||||
gateway?: boolean;
|
||||
tproxyChain?: string;
|
||||
}) {
|
||||
let child: ChildProcess | null = null;
|
||||
let configHash = '';
|
||||
let startedAt = null;
|
||||
let startedAt: string | null = null;
|
||||
|
||||
const state = () => ({ running: Boolean(child), startedAt });
|
||||
|
||||
@@ -23,7 +31,7 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
await new Promise((resolve) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
resolve();
|
||||
@@ -54,12 +62,12 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
|
||||
if (!force && child && nextHash === configHash) return state();
|
||||
|
||||
await stop();
|
||||
let current;
|
||||
let current: ChildProcess;
|
||||
try {
|
||||
current = spawn('sing-box', ['run', '-c', configPath], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
current.once('spawn', resolve);
|
||||
current.once('error', reject);
|
||||
});
|
||||
@@ -6,24 +6,49 @@ import {
|
||||
createServerId,
|
||||
normalizeServer,
|
||||
serverIdentityKey,
|
||||
type NormalizedServer,
|
||||
} from '../shared/serverIdentity.js';
|
||||
import type { HarborServer } from '../shared/contracts/state.js';
|
||||
import { atomicWriteFile } from './services/stateStore.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
|
||||
|
||||
function usableProxyOutbound(outbound) {
|
||||
const host = String(outbound?.server || '').trim().toLowerCase();
|
||||
const port = Number(outbound?.server_port);
|
||||
interface SubscriptionOutbound extends Record<string, unknown> {
|
||||
type?: unknown;
|
||||
tag?: unknown;
|
||||
server?: unknown;
|
||||
server_port?: unknown;
|
||||
}
|
||||
|
||||
interface FetchSubscriptionOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function outboundRecord(value: unknown): SubscriptionOutbound {
|
||||
return record(value) as SubscriptionOutbound;
|
||||
}
|
||||
|
||||
function usableProxyOutbound(value: unknown) {
|
||||
const outbound = outboundRecord(value);
|
||||
const host = String(outbound.server || '').trim().toLowerCase();
|
||||
const port = Number(outbound.server_port);
|
||||
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function rejectedSubscriptionCode(outbounds) {
|
||||
const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
|
||||
function rejectedSubscriptionCode(outbounds: unknown[]) {
|
||||
const labels = outbounds.map((value) => String(outboundRecord(value).tag || '').toLowerCase()).join(' ');
|
||||
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
|
||||
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
|
||||
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
|
||||
return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
|
||||
return outbounds.some((value) => UNSPECIFIED_HOSTS.has(String(outboundRecord(value).server || '').trim().toLowerCase()))
|
||||
? 'SUBSCRIPTION_REJECTED'
|
||||
: 'SUBSCRIPTION_INVALID';
|
||||
}
|
||||
@@ -48,8 +73,8 @@ export function subscriptionHeaders() {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserInfo(headerValue) {
|
||||
const result = {};
|
||||
export function parseUserInfo(headerValue: unknown): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
if (!headerValue) return result;
|
||||
|
||||
for (const part of String(headerValue).split(';')) {
|
||||
@@ -62,7 +87,7 @@ export function parseUserInfo(headerValue) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseVlessUrl(rawUrl) {
|
||||
export function parseVlessUrl(rawUrl: string) {
|
||||
if (!rawUrl.startsWith('vless://')) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
@@ -115,7 +140,7 @@ export function parseVlessUrl(rawUrl) {
|
||||
};
|
||||
}
|
||||
|
||||
function maybeDecodeBase64(content) {
|
||||
function maybeDecodeBase64(content: string) {
|
||||
const compact = content.trim().replace(/\s+/g, '');
|
||||
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
|
||||
|
||||
@@ -127,18 +152,19 @@ function maybeDecodeBase64(content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
export function normalizeSubscriptionConfig(value) {
|
||||
const parsedConfig = value && typeof value === 'object' ? value : {};
|
||||
export function normalizeSubscriptionConfig(value: unknown) {
|
||||
const parsedConfig = record(value);
|
||||
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||
const servers = [];
|
||||
const rejectedOutbounds = [];
|
||||
const seen = new Set();
|
||||
const normalizedOutbounds = outbounds.flatMap((outbound) => {
|
||||
if (!outbound || typeof outbound !== 'object') {
|
||||
rejectedOutbounds.push(outbound);
|
||||
const servers: NormalizedServer[] = [];
|
||||
const rejectedOutbounds: unknown[] = [];
|
||||
const seen = new Set<string>();
|
||||
const normalizedOutbounds = outbounds.flatMap((value): Record<string, unknown>[] => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
rejectedOutbounds.push(value);
|
||||
return [];
|
||||
}
|
||||
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
|
||||
const outbound = outboundRecord(value);
|
||||
if (!PROXY_TYPES.has(String(outbound.type || ''))) return [outbound];
|
||||
if (!usableProxyOutbound(outbound)) {
|
||||
rejectedOutbounds.push(outbound);
|
||||
return [];
|
||||
@@ -155,8 +181,8 @@ export function normalizeSubscriptionConfig(value) {
|
||||
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
|
||||
}
|
||||
|
||||
export function parseSubscriptionBody(body) {
|
||||
let parsedConfig;
|
||||
export function parseSubscriptionBody(body: string) {
|
||||
let parsedConfig: unknown;
|
||||
|
||||
try {
|
||||
parsedConfig = JSON.parse(body);
|
||||
@@ -179,8 +205,11 @@ export function parseSubscriptionBody(body) {
|
||||
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
|
||||
}
|
||||
|
||||
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
|
||||
let parsedUrl;
|
||||
async function requestSubscription(
|
||||
url: string,
|
||||
{ fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs }: FetchSubscriptionOptions = {},
|
||||
) {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (cause) {
|
||||
@@ -191,7 +220,7 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
let response;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(parsedUrl, {
|
||||
headers: subscriptionHeaders(),
|
||||
@@ -209,7 +238,11 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
|
||||
return response;
|
||||
}
|
||||
|
||||
export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
|
||||
export function selectRefreshedServer(
|
||||
currentServerId: string,
|
||||
currentServers: readonly HarborServer[],
|
||||
nextServers: readonly HarborServer[],
|
||||
) {
|
||||
if (!currentServerId) return '';
|
||||
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
|
||||
const previous = currentServers.find((server) => server.id === currentServerId);
|
||||
@@ -219,7 +252,7 @@ export function selectRefreshedServer(currentServerId, currentServers, nextServe
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url, options) {
|
||||
export async function fetchSubscription(url: string, options: FetchSubscriptionOptions = {}) {
|
||||
const response = await requestSubscription(url, options);
|
||||
|
||||
const body = await response.text();
|
||||
@@ -1,13 +1,13 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { HARBOR_VERSIONS } from '../shared/versions.js';
|
||||
|
||||
export function detectSingBoxVersion(run = spawnSync) {
|
||||
export function detectSingBoxVersion(run: typeof spawnSync = spawnSync) {
|
||||
const result = run('sing-box', ['version'], { encoding: 'utf8', timeout: 1000 });
|
||||
const match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
export function buildVersionInfo(appMode, run = spawnSync) {
|
||||
export function buildVersionInfo(appMode: string, run: typeof spawnSync = spawnSync) {
|
||||
const client = appMode === 'client';
|
||||
return {
|
||||
apiVersion: 1,
|
||||
@@ -19,7 +19,10 @@ export function buildVersionInfo(appMode, run = spawnSync) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGatewayVersionInfo(controlInfo, dataplaneState) {
|
||||
export function buildGatewayVersionInfo(
|
||||
controlInfo: Record<string, unknown>,
|
||||
dataplaneState: { gatewayBackendVersion?: unknown; singBoxVersion?: unknown } | null | undefined,
|
||||
) {
|
||||
return {
|
||||
...controlInfo,
|
||||
runtime: {
|
||||
Reference in New Issue
Block a user