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
@@ -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 (
-218
View File
@@ -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);
}
+302
View File
@@ -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);
}
+21 -6
View File
@@ -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);