Refactor VPN proxy components and update related behavior
This commit is contained in:
+238
-36
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user