Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 01:27:46 +03:00
parent c89e56942a
commit aa9c959368
58 changed files with 4234 additions and 2755 deletions
+238 -36
View File
@@ -20,18 +20,53 @@ export interface RouteRule {
enabled: boolean;
}
export interface StoredProfile {
id: string;
label: string;
subscriptionUrl: string;
subscriptionConfig: unknown;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt: string | null;
desiredServerId: string;
lastRefreshAttemptAt: string | null;
lastRefreshErrorCode: string | null;
}
export interface ProfileSnapshot {
id: string;
label: string;
subscription: {
status: 'ready' | 'stale';
host: string;
fetchedAt: string | null;
userInfo: Record<string, unknown>;
lastRefreshAttemptAt: string | null;
errorCode: string | null;
};
desiredServerId: string;
servers: HarborServer[];
}
export interface StateSnapshot {
apiVersion: 1;
revision: number;
generatedAt: string;
mode: HarborMode;
profiles: ProfileSnapshot[];
subscription: {
status: 'missing' | 'ready';
status: 'missing' | 'ready' | 'stale';
host: string;
fetchedAt: string | null;
userInfo: Record<string, unknown>;
};
selection: { desiredServerId: string; appliedServerId: string };
selection: {
desiredProfileId: string;
desiredServerId: string;
appliedProfileId: string;
appliedServerId: string;
appliedServerSnapshot: HarborServer | null;
};
connection: {
desired: ConnectionState;
process: ConnectionState;
@@ -56,24 +91,36 @@ export interface StateSnapshot {
status: OperationStatus;
startedAt: string | null;
error: string | null;
profileId: string | null;
serverId: string | null;
};
// One-release projection of the desired profile for older clients.
servers: HarborServer[];
}
export interface StoredState extends Record<string, unknown> {
export interface PersistedState extends Record<string, unknown> {
revision: number;
selectedServerId: string;
profiles: StoredProfile[];
desiredProfileId: string;
appliedProfileId: string;
appliedServerId: string;
selectedTag: string;
appliedTag: string;
servers: HarborServer[];
appliedServerSnapshot: HarborServer | null;
routeRules: RouteRule[];
appliedRouteRules: RouteRule[];
routeRulesRevision: number;
subscriptionUrl?: string;
connectionDesired?: ConnectionState;
gatewayAutoEnabled?: boolean;
userInfo?: Record<string, unknown>;
}
// Legacy fields are derived in memory for bounded callers during the v5 cutover.
// migrateStoredState strips them before every persisted write.
export interface StoredState extends PersistedState {
subscriptionUrl: string;
selectedServerId: string;
selectedTag: string;
appliedTag: string;
servers: HarborServer[];
userInfo: Record<string, unknown>;
fetchedAt?: string;
}
@@ -97,6 +144,8 @@ export interface OperationState {
status: OperationStatus;
startedAt: string | null;
error: string | null;
profileId?: string | null;
serverId?: string | null;
}
const MODES = new Set<HarborMode>(['client', 'gateway']);
@@ -109,37 +158,152 @@ 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)
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const servers = normalizeServers(state.servers) as HarborServer[];
const selectedServerId = resolveServerId(
}
function publicSubscriptionHost(value: unknown) {
try {
return `${new URL(String(value)).host}/…`;
} catch {
return '';
}
}
function normalizeProfile(value: unknown, index: number): StoredProfile {
const candidate = record(value);
const servers = normalizeServers(candidate.servers) as HarborServer[];
const desiredServerId = resolveServerId(
servers,
identityText(candidate.desiredServerId),
identityText(candidate.selectedTag),
);
return {
id: identityText(candidate.id) || `profile_${index + 1}`,
label: identityText(candidate.label) || `Подписка ${index + 1}`,
subscriptionUrl: identityText(candidate.subscriptionUrl),
subscriptionConfig: candidate.subscriptionConfig ?? null,
servers,
userInfo: record(candidate.userInfo),
fetchedAt: dateOrNull(candidate.fetchedAt),
desiredServerId,
lastRefreshAttemptAt: dateOrNull(candidate.lastRefreshAttemptAt),
lastRefreshErrorCode: nullableText(candidate.lastRefreshErrorCode),
};
}
function normalizeAppliedServer(value: unknown): HarborServer | null {
return (normalizeServers(value ? [value] : []) as HarborServer[])[0] || null;
}
export function profileById(state: Pick<PersistedState, 'profiles'>, profileId: unknown) {
const id = identityText(profileId);
return state.profiles.find((profile) => profile.id === id) || null;
}
export function desiredProfile(state: Pick<PersistedState, 'profiles' | 'desiredProfileId'>) {
return profileById(state, state.desiredProfileId);
}
export function appliedProfile(state: Pick<PersistedState, 'profiles' | 'appliedProfileId'>) {
return profileById(state, state.appliedProfileId);
}
export function normalizeStoredState(value: unknown): StoredState {
const state = record(value);
const legacyServers = normalizeServers(state.servers) as HarborServer[];
const legacySelectedServerId = resolveServerId(
legacyServers,
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);
const legacyAppliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(legacyServers, identityText(state.appliedServerId))
: resolveServerId(legacyServers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
const suppliedProfiles = Array.isArray(state.profiles)
? state.profiles.map(normalizeProfile)
: [];
const profiles = Array.isArray(state.profiles)
? suppliedProfiles.filter((profile, index) => (
suppliedProfiles.findIndex((candidate) => candidate.id === profile.id) === index
))
: identityText(state.subscriptionUrl) || legacyServers.length
? [normalizeProfile({
id: 'profile_primary',
label: 'Основной',
subscriptionUrl: state.subscriptionUrl,
subscriptionConfig: state.subscriptionConfig,
servers: legacyServers,
userInfo: state.userInfo,
fetchedAt: state.fetchedAt,
desiredServerId: legacySelectedServerId,
}, 0)]
: [];
const requestedDesiredProfileId = identityText(state.desiredProfileId);
const desiredProfileId = profileById({ profiles }, requestedDesiredProfileId)?.id
|| (profiles.length === 1 ? profiles[0].id : '');
const requestedAppliedProfileId = identityText(state.appliedProfileId);
const appliedProfileId = profileById({ profiles }, requestedAppliedProfileId)?.id
|| (legacyAppliedServerId && profiles.length === 1 ? profiles[0].id : '');
const selectedProfile = profileById({ profiles }, desiredProfileId);
const currentAppliedProfile = profileById({ profiles }, appliedProfileId);
const normalizedSnapshot = normalizeAppliedServer(state.appliedServerSnapshot);
const explicitAppliedServerId = identityText(state.appliedServerId);
const appliedServerId = explicitAppliedServerId && (
currentAppliedProfile?.servers.some((server) => server.id === explicitAppliedServerId)
|| normalizedSnapshot?.id === explicitAppliedServerId
)
? explicitAppliedServerId
: legacyAppliedServerId;
const appliedServerSnapshot = currentAppliedProfile?.servers.find(
(server) => server.id === appliedServerId,
) || (normalizedSnapshot?.id === appliedServerId ? normalizedSnapshot : null);
const selectedServerId = selectedProfile?.desiredServerId || '';
const selectedServer = selectedProfile?.servers.find((server) => server.id === selectedServerId);
return {
...state,
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0
? state.revision
: 0,
selectedServerId,
profiles,
desiredProfileId,
appliedProfileId,
appliedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServer?.label || '',
servers,
appliedServerSnapshot,
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,
subscriptionUrl: selectedProfile?.subscriptionUrl || '',
selectedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServerSnapshot?.label || '',
servers: selectedProfile?.servers || [],
userInfo: selectedProfile?.userInfo || {},
fetchedAt: selectedProfile?.fetchedAt || undefined,
};
}
function profileSnapshot(profile: StoredProfile): ProfileSnapshot {
return {
id: profile.id,
label: profile.label,
subscription: {
status: profile.lastRefreshErrorCode ? 'stale' : 'ready',
host: publicSubscriptionHost(profile.subscriptionUrl),
fetchedAt: dateOrNull(profile.fetchedAt),
userInfo: profile.userInfo,
lastRefreshAttemptAt: dateOrNull(profile.lastRefreshAttemptAt),
errorCode: nullableText(profile.lastRefreshErrorCode),
},
desiredServerId: profile.desiredServerId,
servers: profile.servers,
};
}
@@ -149,7 +313,6 @@ export function createStateSnapshot({
gatewayAuto,
appMode,
configExists,
subscriptionHost,
operation = { kind: null, status: 'idle', startedAt: null, error: null },
now = new Date(),
}: {
@@ -158,17 +321,19 @@ export function createStateSnapshot({
gatewayAuto?: GatewayAutoState | null;
appMode?: string;
configExists: boolean;
subscriptionHost: string;
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 selectedProfile = desiredProfile(stored);
const profiles = stored.profiles.map(profileSnapshot);
const selectedSnapshot = profiles.find((profile) => profile.id === stored.desiredProfileId) || null;
const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired
: configExists ? 'running' : 'stopped';
const servers = stored.servers;
const running = Boolean(runtime?.running);
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
const routeReason = mode !== 'client'
@@ -178,26 +343,36 @@ export function createStateSnapshot({
: routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
const activeLocalRules = running ? stored.appliedRouteRules : [];
const appliedServerSnapshot = stored.appliedServerSnapshot;
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 : {},
profiles,
subscription: selectedSnapshot ? {
status: selectedSnapshot.subscription.status,
host: selectedSnapshot.subscription.host,
fetchedAt: selectedSnapshot.subscription.fetchedAt,
userInfo: selectedSnapshot.subscription.userInfo,
} : {
status: 'missing',
host: '',
fetchedAt: null,
userInfo: {},
},
selection: {
desiredServerId: stored.selectedServerId,
desiredProfileId: selectedProfile?.id || '',
desiredServerId: selectedProfile?.desiredServerId || '',
appliedProfileId: stored.appliedProfileId,
appliedServerId: stored.appliedServerId,
appliedServerSnapshot,
},
connection: {
desired,
process: runtime?.running ? 'running' : 'stopped',
process: running ? 'running' : 'stopped',
startedAt: dateOrNull(runtime?.startedAt),
lastError: null,
},
@@ -219,8 +394,10 @@ export function createStateSnapshot({
status: operation.status,
startedAt: nullableText(operation.startedAt),
error: nullableText(operation.error),
profileId: nullableText(operation.profileId),
serverId: nullableText(operation.serverId),
},
servers: servers as HarborServer[],
servers: selectedProfile?.servers || [],
});
}
@@ -245,6 +422,23 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
);
const validProfile = (profile: ProfileSnapshot) => (
profile &&
typeof profile.id === 'string' && Boolean(profile.id) &&
typeof profile.label === 'string' && Boolean(profile.label) &&
!Object.hasOwn(profile, 'subscriptionUrl') &&
!Object.hasOwn(profile, 'subscriptionConfig') &&
profile.subscription &&
['ready', 'stale'].includes(profile.subscription.status) &&
typeof profile.subscription.host === 'string' &&
!Object.hasOwn(profile.subscription, 'url') &&
nullableDate(profile.subscription.fetchedAt) &&
profile.subscription.userInfo && typeof profile.subscription.userInfo === 'object' &&
nullableDate(profile.subscription.lastRefreshAttemptAt) &&
nullableString(profile.subscription.errorCode) &&
typeof profile.desiredServerId === 'string' &&
Array.isArray(profile.servers) && profile.servers.every(validServer)
);
if (
!snapshot ||
@@ -253,16 +447,22 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
candidate.revision < 0 ||
!validDate(candidate.generatedAt) ||
!MODES.has(candidate.mode) ||
!Array.isArray(candidate.profiles) ||
!candidate.profiles.every(validProfile) ||
new Set(candidate.profiles.map((profile) => profile.id)).size !== candidate.profiles.length ||
!candidate.subscription ||
!['missing', 'ready'].includes(candidate.subscription.status) ||
!['missing', 'ready', 'stale'].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.desiredProfileId !== 'string' ||
typeof candidate.selection.desiredServerId !== 'string' ||
typeof candidate.selection.appliedProfileId !== 'string' ||
typeof candidate.selection.appliedServerId !== 'string' ||
!(candidate.selection.appliedServerSnapshot === null || validServer(candidate.selection.appliedServerSnapshot)) ||
!candidate.connection ||
!CONNECTION_STATES.has(candidate.connection.desired) ||
!CONNECTION_STATES.has(candidate.connection.process) ||
@@ -288,6 +488,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!OPERATION_STATES.has(candidate.operation.status) ||
!nullableDate(candidate.operation.startedAt) ||
!nullableString(candidate.operation.error) ||
!nullableString(candidate.operation.profileId) ||
!nullableString(candidate.operation.serverId) ||
!Array.isArray(candidate.servers) ||
!candidate.servers.every(validServer)
) {
+3
View File
@@ -14,6 +14,9 @@ export const ERROR_DEFINITIONS = Object.freeze({
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
PROFILE_NOT_FOUND: { status: 404, message: 'Подписка больше недоступна.', retryable: false },
PROFILE_NAME_CONFLICT: { status: 409, message: 'Подписка с таким именем уже существует.', retryable: false },
PROFILE_IN_USE: { status: 409, message: 'Сначала переключите или остановите активную подписку.', retryable: false },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.22.1',
gatewayClient: '0.23.1',
gatewayBackend: '0.23.1',
macClient: '0.23.0',
gatewayClient: '0.24.0',
gatewayBackend: '0.24.0',
});
export interface ParsedVersion {