Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
+37
View File
@@ -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;
},
};
}
+21
View File
@@ -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;
},
};
}
+91
View File
@@ -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;
},
};
}
+31
View File
@@ -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;
},
};
}