Refactor VPN proxy client implementation
This commit is contained in:
@@ -19,7 +19,24 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
||||
|
||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||
|
||||
export function assessConnectivity(direct, vpn) {
|
||||
export interface ConnectivitySiteResult {
|
||||
id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
httpStatus?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ConnectivityPathResult {
|
||||
available?: boolean;
|
||||
internetAvailable: boolean;
|
||||
ipv4: { addresses: string[]; [key: string]: unknown };
|
||||
ipv6: string | null;
|
||||
sites: ConnectivitySiteResult[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function assessConnectivity(direct: ConnectivityPathResult, vpn: ConnectivityPathResult) {
|
||||
const comparisons = direct.sites.map(({ id, label }) => {
|
||||
const directSite = direct.sites.find((site) => site.id === id);
|
||||
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
||||
@@ -29,7 +46,7 @@ export function assessConnectivity(direct, vpn) {
|
||||
assessment = 'available';
|
||||
} else if (
|
||||
directSite?.status === 'responded'
|
||||
&& [403, 451].includes(directSite.httpStatus)
|
||||
&& [403, 451].includes(Number(directSite.httpStatus))
|
||||
&& vpnSite?.status === 'available'
|
||||
) assessment = 'likely-direct-restriction';
|
||||
else if (
|
||||
@@ -1,218 +0,0 @@
|
||||
import { normalizeRouteRules } from '../routingRules.js';
|
||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||
|
||||
const MODES = new Set(['client', 'gateway']);
|
||||
const CONNECTION_STATES = new Set(['running', 'stopped']);
|
||||
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
|
||||
|
||||
const text = (value) => String(value || '').trim();
|
||||
const nullableText = (value) => value == null ? null : String(value);
|
||||
const dateOrNull = (value) => (
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
|
||||
);
|
||||
|
||||
export function normalizeStoredState(value) {
|
||||
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const servers = normalizeServers(state.servers);
|
||||
const selectedServerId = resolveServerId(servers, state.selectedServerId, state.selectedTag);
|
||||
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
|
||||
? resolveServerId(servers, state.appliedServerId)
|
||||
: resolveServerId(servers, '', state.appliedTag || state.selectedTag);
|
||||
const selectedServer = servers.find((server) => server.id === selectedServerId);
|
||||
const appliedServer = servers.find((server) => server.id === appliedServerId);
|
||||
return {
|
||||
...state,
|
||||
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
|
||||
selectedServerId,
|
||||
appliedServerId,
|
||||
selectedTag: selectedServer?.label || '',
|
||||
appliedTag: appliedServer?.label || '',
|
||||
servers,
|
||||
routeRules: normalizeRouteRules(state.routeRules),
|
||||
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
|
||||
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
||||
? state.routeRulesRevision
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateSnapshot({
|
||||
storedState,
|
||||
runtime,
|
||||
gatewayAuto,
|
||||
appMode,
|
||||
configExists,
|
||||
subscriptionHost,
|
||||
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
||||
now = new Date(),
|
||||
}) {
|
||||
const stored = normalizeStoredState(storedState);
|
||||
const mode = MODES.has(appMode) ? appMode : 'gateway';
|
||||
const hasSubscription = Boolean(stored.subscriptionUrl);
|
||||
const desired = CONNECTION_STATES.has(stored.connectionDesired)
|
||||
? stored.connectionDesired
|
||||
: configExists ? 'running' : 'stopped';
|
||||
const servers = stored.servers;
|
||||
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
||||
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
|
||||
const routeReason = mode !== 'client'
|
||||
? 'gateway-host'
|
||||
: !gatewayAutoEnabled
|
||||
? 'disabled'
|
||||
: routeMode === 'gateway-direct'
|
||||
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
|
||||
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
|
||||
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||
|
||||
return assertStateSnapshot({
|
||||
apiVersion: 1,
|
||||
revision: stored.revision,
|
||||
generatedAt: now.toISOString(),
|
||||
mode,
|
||||
subscription: {
|
||||
status: hasSubscription ? 'ready' : 'missing',
|
||||
host: hasSubscription ? subscriptionHost : '',
|
||||
fetchedAt: dateOrNull(stored.fetchedAt),
|
||||
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
|
||||
},
|
||||
selection: {
|
||||
desiredServerId: stored.selectedServerId,
|
||||
appliedServerId: stored.appliedServerId,
|
||||
},
|
||||
connection: {
|
||||
desired,
|
||||
process: runtime?.running ? 'running' : 'stopped',
|
||||
startedAt: dateOrNull(runtime?.startedAt),
|
||||
lastError: null,
|
||||
},
|
||||
route: {
|
||||
mode: routeMode,
|
||||
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
||||
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
|
||||
lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
|
||||
autoEnabled: mode === 'client' && gatewayAutoEnabled,
|
||||
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
|
||||
reason: routeReason,
|
||||
localRules: stored.routeRules,
|
||||
activeLocalRules,
|
||||
localRulesRevision: stored.routeRulesRevision,
|
||||
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
||||
},
|
||||
operation: {
|
||||
kind: nullableText(operation.kind),
|
||||
status: operation.status,
|
||||
startedAt: nullableText(operation.startedAt),
|
||||
error: nullableText(operation.error),
|
||||
},
|
||||
servers,
|
||||
});
|
||||
}
|
||||
|
||||
export function withStateV0Compatibility(snapshot, {
|
||||
storedState,
|
||||
gatewayAuto,
|
||||
port,
|
||||
proxyPort,
|
||||
configExists,
|
||||
}) {
|
||||
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' ? {
|
||||
mode: gatewayAuto?.mode || 'local-vpn',
|
||||
enabled: stored.gatewayAutoEnabled !== false,
|
||||
available: Boolean(gatewayAuto?.gatewayId),
|
||||
address: gatewayAuto?.gateway?.gateway || '',
|
||||
uiOrigin: gatewayAuto?.uiOrigin || '',
|
||||
interface: gatewayAuto?.gateway?.interface || '',
|
||||
failures: Number(gatewayAuto?.failures) || 0,
|
||||
lastError: gatewayAuto?.lastError || '',
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertStateSnapshot(snapshot) {
|
||||
const validDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const nullableDate = (value) => value === null || validDate(value);
|
||||
const nullableString = (value) => value === null || typeof value === 'string';
|
||||
const validServer = (server) => (
|
||||
server &&
|
||||
typeof server.id === 'string' &&
|
||||
typeof server.label === 'string' &&
|
||||
typeof server.host === 'string' &&
|
||||
Number.isInteger(server.port) &&
|
||||
server.port >= 0 &&
|
||||
typeof server.protocol === 'string'
|
||||
);
|
||||
const validRouteRule = (rule) => (
|
||||
rule &&
|
||||
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
||||
typeof rule.value === 'string' &&
|
||||
Boolean(rule.value) &&
|
||||
typeof rule.enabled === 'boolean'
|
||||
);
|
||||
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.apiVersion !== 1 ||
|
||||
!Number.isSafeInteger(snapshot.revision) ||
|
||||
snapshot.revision < 0 ||
|
||||
!validDate(snapshot.generatedAt) ||
|
||||
!MODES.has(snapshot.mode) ||
|
||||
!snapshot.subscription ||
|
||||
!['missing', 'ready'].includes(snapshot.subscription.status) ||
|
||||
typeof snapshot.subscription.host !== 'string' ||
|
||||
Object.hasOwn(snapshot.subscription, 'url') ||
|
||||
!nullableDate(snapshot.subscription.fetchedAt) ||
|
||||
!snapshot.subscription.userInfo ||
|
||||
typeof snapshot.subscription.userInfo !== 'object' ||
|
||||
!snapshot.selection ||
|
||||
typeof snapshot.selection.desiredServerId !== 'string' ||
|
||||
typeof snapshot.selection.appliedServerId !== 'string' ||
|
||||
!snapshot.connection ||
|
||||
!CONNECTION_STATES.has(snapshot.connection.desired) ||
|
||||
!CONNECTION_STATES.has(snapshot.connection.process) ||
|
||||
!nullableDate(snapshot.connection.startedAt) ||
|
||||
!nullableString(snapshot.connection.lastError) ||
|
||||
!snapshot.route ||
|
||||
typeof snapshot.route.mode !== 'string' ||
|
||||
!nullableString(snapshot.route.gatewayAddress) ||
|
||||
!nullableString(snapshot.route.gatewayUiOrigin) ||
|
||||
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
||||
typeof snapshot.route.autoEnabled !== 'boolean' ||
|
||||
typeof snapshot.route.fallbackPreference !== 'string' ||
|
||||
typeof snapshot.route.reason !== 'string' ||
|
||||
!Array.isArray(snapshot.route.localRules) ||
|
||||
!snapshot.route.localRules.every(validRouteRule) ||
|
||||
!Array.isArray(snapshot.route.activeLocalRules) ||
|
||||
!snapshot.route.activeLocalRules.every(validRouteRule) ||
|
||||
!Number.isSafeInteger(snapshot.route.localRulesRevision) ||
|
||||
snapshot.route.localRulesRevision < 0 ||
|
||||
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
|
||||
!snapshot.operation ||
|
||||
!nullableString(snapshot.operation.kind) ||
|
||||
!OPERATION_STATES.has(snapshot.operation.status) ||
|
||||
!nullableDate(snapshot.operation.startedAt) ||
|
||||
!nullableString(snapshot.operation.error) ||
|
||||
!Array.isArray(snapshot.servers) ||
|
||||
!snapshot.servers.every(validServer)
|
||||
) {
|
||||
throw new TypeError('Invalid Harbor state snapshot v1');
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function isSameRules(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { normalizeRouteRules } from '../routingRules.js';
|
||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||
|
||||
export type HarborMode = 'client' | 'gateway';
|
||||
export type ConnectionState = 'running' | 'stopped';
|
||||
export type OperationStatus = 'idle' | 'running' | 'failed';
|
||||
|
||||
export interface HarborServer {
|
||||
id: string;
|
||||
label: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RouteRule {
|
||||
type: 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface StateSnapshot {
|
||||
apiVersion: 1;
|
||||
revision: number;
|
||||
generatedAt: string;
|
||||
mode: HarborMode;
|
||||
subscription: {
|
||||
status: 'missing' | 'ready';
|
||||
host: string;
|
||||
fetchedAt: string | null;
|
||||
userInfo: Record<string, unknown>;
|
||||
};
|
||||
selection: { desiredServerId: string; appliedServerId: string };
|
||||
connection: {
|
||||
desired: ConnectionState;
|
||||
process: ConnectionState;
|
||||
startedAt: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
route: {
|
||||
mode: string;
|
||||
gatewayAddress: string | null;
|
||||
gatewayUiOrigin: string | null;
|
||||
lastVerifiedAt: string | null;
|
||||
autoEnabled: boolean;
|
||||
fallbackPreference: string;
|
||||
reason: string;
|
||||
localRules: RouteRule[];
|
||||
activeLocalRules: RouteRule[];
|
||||
localRulesRevision: number;
|
||||
localRulesPendingRestart: boolean;
|
||||
};
|
||||
operation: {
|
||||
kind: string | null;
|
||||
status: OperationStatus;
|
||||
startedAt: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
servers: HarborServer[];
|
||||
}
|
||||
|
||||
export interface StoredState extends Record<string, unknown> {
|
||||
revision: number;
|
||||
selectedServerId: string;
|
||||
appliedServerId: string;
|
||||
selectedTag: string;
|
||||
appliedTag: string;
|
||||
servers: HarborServer[];
|
||||
routeRules: RouteRule[];
|
||||
appliedRouteRules: RouteRule[];
|
||||
routeRulesRevision: number;
|
||||
subscriptionUrl?: string;
|
||||
connectionDesired?: ConnectionState;
|
||||
gatewayAutoEnabled?: boolean;
|
||||
userInfo?: Record<string, unknown>;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
interface RuntimeState {
|
||||
running?: boolean;
|
||||
startedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface GatewayAutoState {
|
||||
mode?: string;
|
||||
gatewayId?: string;
|
||||
gateway?: { gateway?: string; interface?: string } | null;
|
||||
uiOrigin?: string;
|
||||
failures?: number;
|
||||
lastError?: string;
|
||||
lastVerifiedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface OperationState {
|
||||
kind: string | null;
|
||||
status: OperationStatus;
|
||||
startedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const MODES = new Set<HarborMode>(['client', 'gateway']);
|
||||
const CONNECTION_STATES = new Set<ConnectionState>(['running', 'stopped']);
|
||||
const OPERATION_STATES = new Set<OperationStatus>(['idle', 'running', 'failed']);
|
||||
|
||||
const nullableText = (value: unknown) => value == null ? null : String(value);
|
||||
const identityText = (value: unknown) => String(value || '').trim();
|
||||
const dateOrNull = (value: unknown) => (
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
|
||||
);
|
||||
|
||||
export function normalizeStoredState(value: unknown): StoredState {
|
||||
const state: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
const servers = normalizeServers(state.servers) as HarborServer[];
|
||||
const selectedServerId = resolveServerId(
|
||||
servers,
|
||||
identityText(state.selectedServerId),
|
||||
identityText(state.selectedTag),
|
||||
);
|
||||
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
|
||||
? resolveServerId(servers, identityText(state.appliedServerId))
|
||||
: resolveServerId(servers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
|
||||
const selectedServer = servers.find((server: HarborServer) => server.id === selectedServerId);
|
||||
const appliedServer = servers.find((server: HarborServer) => server.id === appliedServerId);
|
||||
return {
|
||||
...state,
|
||||
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0
|
||||
? state.revision
|
||||
: 0,
|
||||
selectedServerId,
|
||||
appliedServerId,
|
||||
selectedTag: selectedServer?.label || '',
|
||||
appliedTag: appliedServer?.label || '',
|
||||
servers,
|
||||
routeRules: normalizeRouteRules(state.routeRules) as RouteRule[],
|
||||
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules) as RouteRule[],
|
||||
routeRulesRevision: typeof state.routeRulesRevision === 'number'
|
||||
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
||||
? state.routeRulesRevision
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStateSnapshot({
|
||||
storedState,
|
||||
runtime,
|
||||
gatewayAuto,
|
||||
appMode,
|
||||
configExists,
|
||||
subscriptionHost,
|
||||
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
||||
now = new Date(),
|
||||
}: {
|
||||
storedState: unknown;
|
||||
runtime?: RuntimeState | null;
|
||||
gatewayAuto?: GatewayAutoState | null;
|
||||
appMode?: string;
|
||||
configExists: boolean;
|
||||
subscriptionHost: string;
|
||||
operation?: OperationState;
|
||||
now?: Date;
|
||||
}): StateSnapshot {
|
||||
const stored = normalizeStoredState(storedState);
|
||||
const mode: HarborMode = appMode === 'client' || appMode === 'gateway' ? appMode : 'gateway';
|
||||
const hasSubscription = Boolean(stored.subscriptionUrl);
|
||||
const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired)
|
||||
? stored.connectionDesired
|
||||
: configExists ? 'running' : 'stopped';
|
||||
const servers = stored.servers;
|
||||
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
||||
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
|
||||
const routeReason = mode !== 'client'
|
||||
? 'gateway-host'
|
||||
: !gatewayAutoEnabled
|
||||
? 'disabled'
|
||||
: routeMode === 'gateway-direct'
|
||||
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
|
||||
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
|
||||
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||
|
||||
return assertStateSnapshot({
|
||||
apiVersion: 1,
|
||||
revision: stored.revision,
|
||||
generatedAt: now.toISOString(),
|
||||
mode,
|
||||
subscription: {
|
||||
status: hasSubscription ? 'ready' : 'missing',
|
||||
host: hasSubscription ? subscriptionHost : '',
|
||||
fetchedAt: dateOrNull(stored.fetchedAt),
|
||||
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
|
||||
},
|
||||
selection: {
|
||||
desiredServerId: stored.selectedServerId,
|
||||
appliedServerId: stored.appliedServerId,
|
||||
},
|
||||
connection: {
|
||||
desired,
|
||||
process: runtime?.running ? 'running' : 'stopped',
|
||||
startedAt: dateOrNull(runtime?.startedAt),
|
||||
lastError: null,
|
||||
},
|
||||
route: {
|
||||
mode: routeMode,
|
||||
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
||||
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
|
||||
lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
|
||||
autoEnabled: mode === 'client' && gatewayAutoEnabled,
|
||||
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
|
||||
reason: routeReason,
|
||||
localRules: stored.routeRules,
|
||||
activeLocalRules,
|
||||
localRulesRevision: stored.routeRulesRevision,
|
||||
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
||||
},
|
||||
operation: {
|
||||
kind: nullableText(operation.kind),
|
||||
status: operation.status,
|
||||
startedAt: nullableText(operation.startedAt),
|
||||
error: nullableText(operation.error),
|
||||
},
|
||||
servers: servers as HarborServer[],
|
||||
});
|
||||
}
|
||||
|
||||
export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
const candidate = snapshot as StateSnapshot;
|
||||
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const nullableDate = (value: unknown) => value === null || validDate(value);
|
||||
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
||||
const validServer = (server: HarborServer) => (
|
||||
server &&
|
||||
typeof server.id === 'string' &&
|
||||
typeof server.label === 'string' &&
|
||||
typeof server.host === 'string' &&
|
||||
Number.isInteger(server.port) &&
|
||||
server.port >= 0 &&
|
||||
typeof server.protocol === 'string'
|
||||
);
|
||||
const validRouteRule = (rule: RouteRule) => (
|
||||
rule &&
|
||||
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
||||
typeof rule.value === 'string' &&
|
||||
Boolean(rule.value) &&
|
||||
typeof rule.enabled === 'boolean'
|
||||
);
|
||||
|
||||
if (
|
||||
!snapshot ||
|
||||
candidate.apiVersion !== 1 ||
|
||||
!Number.isSafeInteger(candidate.revision) ||
|
||||
candidate.revision < 0 ||
|
||||
!validDate(candidate.generatedAt) ||
|
||||
!MODES.has(candidate.mode) ||
|
||||
!candidate.subscription ||
|
||||
!['missing', 'ready'].includes(candidate.subscription.status) ||
|
||||
typeof candidate.subscription.host !== 'string' ||
|
||||
Object.hasOwn(candidate.subscription, 'url') ||
|
||||
!nullableDate(candidate.subscription.fetchedAt) ||
|
||||
!candidate.subscription.userInfo ||
|
||||
typeof candidate.subscription.userInfo !== 'object' ||
|
||||
!candidate.selection ||
|
||||
typeof candidate.selection.desiredServerId !== 'string' ||
|
||||
typeof candidate.selection.appliedServerId !== 'string' ||
|
||||
!candidate.connection ||
|
||||
!CONNECTION_STATES.has(candidate.connection.desired) ||
|
||||
!CONNECTION_STATES.has(candidate.connection.process) ||
|
||||
!nullableDate(candidate.connection.startedAt) ||
|
||||
!nullableString(candidate.connection.lastError) ||
|
||||
!candidate.route ||
|
||||
typeof candidate.route.mode !== 'string' ||
|
||||
!nullableString(candidate.route.gatewayAddress) ||
|
||||
!nullableString(candidate.route.gatewayUiOrigin) ||
|
||||
!nullableDate(candidate.route.lastVerifiedAt) ||
|
||||
typeof candidate.route.autoEnabled !== 'boolean' ||
|
||||
typeof candidate.route.fallbackPreference !== 'string' ||
|
||||
typeof candidate.route.reason !== 'string' ||
|
||||
!Array.isArray(candidate.route.localRules) ||
|
||||
!candidate.route.localRules.every(validRouteRule) ||
|
||||
!Array.isArray(candidate.route.activeLocalRules) ||
|
||||
!candidate.route.activeLocalRules.every(validRouteRule) ||
|
||||
!Number.isSafeInteger(candidate.route.localRulesRevision) ||
|
||||
candidate.route.localRulesRevision < 0 ||
|
||||
typeof candidate.route.localRulesPendingRestart !== 'boolean' ||
|
||||
!candidate.operation ||
|
||||
!nullableString(candidate.operation.kind) ||
|
||||
!OPERATION_STATES.has(candidate.operation.status) ||
|
||||
!nullableDate(candidate.operation.startedAt) ||
|
||||
!nullableString(candidate.operation.error) ||
|
||||
!Array.isArray(candidate.servers) ||
|
||||
!candidate.servers.every(validServer)
|
||||
) {
|
||||
throw new TypeError('Invalid Harbor state snapshot v1');
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function isSameRules(left: RouteRule[], right: RouteRule[]) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
interface ErrorDefinition {
|
||||
status: number;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export const ERROR_DEFINITIONS = Object.freeze({
|
||||
CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true },
|
||||
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
|
||||
@@ -18,24 +24,33 @@ export const ERROR_DEFINITIONS = Object.freeze({
|
||||
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
|
||||
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
|
||||
UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false },
|
||||
});
|
||||
} satisfies Record<string, ErrorDefinition>);
|
||||
|
||||
export function errorDefinition(code) {
|
||||
return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN;
|
||||
export type HarborErrorCode = keyof typeof ERROR_DEFINITIONS;
|
||||
|
||||
export function errorDefinition(code: unknown): ErrorDefinition {
|
||||
return typeof code === 'string' && Object.hasOwn(ERROR_DEFINITIONS, code)
|
||||
? ERROR_DEFINITIONS[code as HarborErrorCode]
|
||||
: ERROR_DEFINITIONS.UNKNOWN;
|
||||
}
|
||||
|
||||
export class HarborError extends Error {
|
||||
constructor(code, { cause, details } = {}) {
|
||||
code: HarborErrorCode;
|
||||
status: number;
|
||||
retryable: boolean;
|
||||
details: unknown;
|
||||
|
||||
constructor(code: string, { cause, details }: { cause?: unknown; details?: unknown } = {}) {
|
||||
const definition = errorDefinition(code);
|
||||
super(definition.message, { cause });
|
||||
this.name = 'HarborError';
|
||||
this.code = ERROR_DEFINITIONS[code] ? code : 'UNKNOWN';
|
||||
this.code = Object.hasOwn(ERROR_DEFINITIONS, code) ? code as HarborErrorCode : 'UNKNOWN';
|
||||
this.status = definition.status;
|
||||
this.retryable = definition.retryable;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHarborError(error) {
|
||||
export function normalizeHarborError(error: unknown) {
|
||||
return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error });
|
||||
}
|
||||
@@ -5,7 +5,21 @@ export const INITIAL_ROUTE_RULES = Object.freeze([
|
||||
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
|
||||
export const MAX_ROUTE_RULES = 200;
|
||||
|
||||
function hostname(value) {
|
||||
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||
|
||||
export interface NormalizedRouteRule {
|
||||
type: RouteRuleType;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function hostname(value: unknown) {
|
||||
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
|
||||
if (!input) throw new TypeError('Domain rule value is required');
|
||||
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `https://${input}`);
|
||||
@@ -14,22 +28,26 @@ function hostname(value) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRule(rule) {
|
||||
const type = String(rule?.type || '').trim();
|
||||
function normalizeRule(input: unknown): NormalizedRouteRule {
|
||||
const rule = record(input);
|
||||
const type = String(rule.type || '').trim();
|
||||
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
|
||||
if (Object.hasOwn(rule || {}, 'enabled') && typeof rule.enabled !== 'boolean') {
|
||||
if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') {
|
||||
throw new TypeError('Invalid domain rule enabled state');
|
||||
}
|
||||
const value = type === 'domain_keyword'
|
||||
? String(rule?.value || '').trim().toLowerCase()
|
||||
: hostname(rule?.value);
|
||||
? String(rule.value || '').trim().toLowerCase()
|
||||
: hostname(rule.value);
|
||||
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
||||
throw new TypeError('Invalid domain rule value');
|
||||
}
|
||||
return { type, value, enabled: rule?.enabled !== false };
|
||||
return { type: type as RouteRuleType, value, enabled: rule.enabled !== false };
|
||||
}
|
||||
|
||||
export function normalizeRouteRules(value, { strict = false } = {}) {
|
||||
export function normalizeRouteRules(
|
||||
value: unknown,
|
||||
{ strict = false }: { strict?: boolean } = {},
|
||||
): NormalizedRouteRule[] {
|
||||
if (!Array.isArray(value)) {
|
||||
if (strict) throw new TypeError('Route rules must be an array');
|
||||
return [];
|
||||
@@ -38,8 +56,8 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
|
||||
throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`);
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
const seen = new Set<string>();
|
||||
const normalized: NormalizedRouteRule[] = [];
|
||||
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
|
||||
try {
|
||||
const rule = normalizeRule(candidate);
|
||||
@@ -54,8 +72,8 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function canAppendRouteRule(rules) {
|
||||
export function canAppendRouteRule(rules: unknown) {
|
||||
return Array.isArray(rules) &&
|
||||
rules.length < MAX_ROUTE_RULES &&
|
||||
rules.every((rule) => String(rule?.value || '').trim());
|
||||
rules.every((rule) => String(record(rule).value || '').trim());
|
||||
}
|
||||
@@ -1,6 +1,39 @@
|
||||
const text = (value) => String(value || '').trim();
|
||||
export interface ServerIdentityInput extends Record<string, unknown> {
|
||||
id?: unknown;
|
||||
label?: unknown;
|
||||
tag?: unknown;
|
||||
host?: unknown;
|
||||
server?: unknown;
|
||||
port?: unknown;
|
||||
server_port?: unknown;
|
||||
protocol?: unknown;
|
||||
type?: unknown;
|
||||
country?: unknown;
|
||||
city?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
|
||||
function hash64(value) {
|
||||
export interface NormalizedServer extends Record<string, unknown> {
|
||||
id: string;
|
||||
label: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
tag: string;
|
||||
server: string;
|
||||
server_port: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const text = (value: unknown) => String(value || '').trim();
|
||||
|
||||
function record(value: unknown): ServerIdentityInput {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as ServerIdentityInput
|
||||
: {};
|
||||
}
|
||||
|
||||
function hash64(value: string) {
|
||||
let hash = 0xcbf29ce484222325n;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= BigInt(value.charCodeAt(index));
|
||||
@@ -9,19 +42,20 @@ function hash64(value) {
|
||||
return hash.toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
export function serverIdentityKey(server) {
|
||||
const protocol = text(server?.protocol || server?.type).toLowerCase();
|
||||
const host = text(server?.host || server?.server).toLowerCase();
|
||||
const port = Number(server?.port || server?.server_port) || 0;
|
||||
export function serverIdentityKey(value: unknown) {
|
||||
const server = record(value);
|
||||
const protocol = text(server.protocol || server.type).toLowerCase();
|
||||
const host = text(server.host || server.server).toLowerCase();
|
||||
const port = Number(server.port || server.server_port) || 0;
|
||||
return `${protocol}\u0000${host}\u0000${port}`;
|
||||
}
|
||||
|
||||
export function createServerId(server) {
|
||||
export function createServerId(server: unknown) {
|
||||
return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`;
|
||||
}
|
||||
|
||||
export function normalizeServer(server) {
|
||||
const source = server && typeof server === 'object' ? server : {};
|
||||
export function normalizeServer(server: unknown): NormalizedServer {
|
||||
const source = record(server);
|
||||
const protocol = text(source.protocol || source.type).toLowerCase();
|
||||
const host = text(source.host || source.server);
|
||||
const port = Number(source.port || source.server_port) || 0;
|
||||
@@ -48,8 +82,8 @@ export function normalizeServer(server) {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeServers(servers) {
|
||||
const seen = new Set();
|
||||
export function normalizeServers(servers: unknown): NormalizedServer[] {
|
||||
const seen = new Set<string>();
|
||||
return (Array.isArray(servers) ? servers : []).flatMap((server) => {
|
||||
const normalized = normalizeServer(server);
|
||||
if (!normalized.id || seen.has(normalized.id)) return [];
|
||||
@@ -58,7 +92,11 @@ export function normalizeServers(servers) {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveServerId(servers, serverId, legacyTag = '') {
|
||||
export function resolveServerId(
|
||||
servers: readonly Pick<NormalizedServer, 'id' | 'label'>[],
|
||||
serverId: unknown,
|
||||
legacyTag: unknown = '',
|
||||
) {
|
||||
const id = text(serverId);
|
||||
if (id) return servers.some((server) => server.id === id) ? id : '';
|
||||
const tag = text(legacyTag);
|
||||
@@ -1,10 +1,22 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.20.5',
|
||||
gatewayClient: '0.21.3',
|
||||
gatewayBackend: '0.21.1',
|
||||
macClient: '0.20.36',
|
||||
gatewayClient: '0.21.21',
|
||||
gatewayBackend: '0.21.20',
|
||||
});
|
||||
|
||||
export function parseVersion(value) {
|
||||
export interface ParsedVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
hotfix: number;
|
||||
}
|
||||
|
||||
export interface HarborVersions {
|
||||
macClient: string;
|
||||
gatewayClient: string;
|
||||
gatewayBackend: string;
|
||||
}
|
||||
|
||||
export function parseVersion(value: unknown): ParsedVersion | null {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
||||
return match ? {
|
||||
major: Number(match[1]),
|
||||
@@ -13,7 +25,7 @@ export function parseVersion(value) {
|
||||
} : null;
|
||||
}
|
||||
|
||||
export function versionCompatibility(versions) {
|
||||
export function versionCompatibility(versions: Partial<HarborVersions> | null | undefined) {
|
||||
const mac = parseVersion(versions?.macClient);
|
||||
const client = parseVersion(versions?.gatewayClient);
|
||||
const backend = parseVersion(versions?.gatewayBackend);
|
||||
Reference in New Issue
Block a user