Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
|
||||
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface HostNetworkState {
|
||||
gateway: string;
|
||||
interface: string;
|
||||
mac: string;
|
||||
observedAt?: number;
|
||||
}
|
||||
|
||||
interface VerifiedGateway {
|
||||
gatewayId: string;
|
||||
uiOrigin?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
type TimerHandle = NodeJS.Timeout;
|
||||
|
||||
interface GatewayAutoServiceDependencies {
|
||||
appMode: string;
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: { readConfig(): unknown | null };
|
||||
config: {
|
||||
build(
|
||||
subscriptionConfig: unknown,
|
||||
selectedServerId: string,
|
||||
routeRules: StoredState['routeRules'],
|
||||
gatewayAuto: GatewayAutoState,
|
||||
): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
restore(value: string): void;
|
||||
remove(): void;
|
||||
};
|
||||
runtime: {
|
||||
isRunning(): boolean;
|
||||
applyCommand(): Promise<RuntimeCommandResult>;
|
||||
restoreRunning(): Promise<unknown>;
|
||||
};
|
||||
discovery: {
|
||||
readHostNetwork(): HostNetworkState | null;
|
||||
probeGateway(input: {
|
||||
gateway: string;
|
||||
subscriptionUrl: string;
|
||||
}): Promise<VerifiedGateway>;
|
||||
};
|
||||
transition: {
|
||||
createInitial(): GatewayAutoState;
|
||||
applyPreference(state: GatewayAutoState, enabled: boolean): GatewayAutoState;
|
||||
next(
|
||||
current: GatewayAutoState,
|
||||
input: {
|
||||
network: HostNetworkState | null;
|
||||
verifiedGateway?: VerifiedGateway | null;
|
||||
error?: string;
|
||||
},
|
||||
): GatewayAutoState;
|
||||
sameRoute(
|
||||
previous: GatewayAutoState['gateway'] | HostNetworkState | null | undefined,
|
||||
current: HostNetworkState | null | undefined,
|
||||
): boolean;
|
||||
};
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
scheduler: {
|
||||
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||
clearInterval(timer: TimerHandle): void;
|
||||
};
|
||||
onRouteChange(state: GatewayAutoState): void;
|
||||
onDiscoveryWarning(reason: string): void;
|
||||
onTimerError(error: unknown): void;
|
||||
}
|
||||
|
||||
interface CommitOptions {
|
||||
reconfigure?: boolean;
|
||||
persistEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface RefreshOptions {
|
||||
reconfigure?: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error && typeof error === 'object' && 'message' in error && error.message
|
||||
? String(error.message)
|
||||
: 'Gateway presence check failed';
|
||||
}
|
||||
|
||||
export function createGatewayAutoService(dependencies: GatewayAutoServiceDependencies) {
|
||||
let current = dependencies.transition.createInitial();
|
||||
let refreshPromise: Promise<GatewayAutoState> | null = null;
|
||||
let discoveryTimer: TimerHandle | null = null;
|
||||
|
||||
const restoreConfig = (previous: string | null) => {
|
||||
if (previous === null) dependencies.config.remove();
|
||||
else dependencies.config.restore(previous);
|
||||
};
|
||||
|
||||
const commitCandidate = async (
|
||||
candidate: GatewayAutoState,
|
||||
{ reconfigure = true, persistEnabled }: CommitOptions = {},
|
||||
) => {
|
||||
const previousGatewayAuto = current;
|
||||
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
|
||||
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
|
||||
if (!stateChanged && persistEnabled === undefined) return current;
|
||||
|
||||
const previousState = dependencies.state.read();
|
||||
const subscriptionConfig = modeChanged
|
||||
? dependencies.subscription.readConfig()
|
||||
: null;
|
||||
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
|
||||
? dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
previousState.selectedServerId,
|
||||
previousState.routeRules,
|
||||
candidate,
|
||||
)
|
||||
: null;
|
||||
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
|
||||
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let gatewayAutoPublished = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if (candidateConfig !== null) {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
if (reconfigure && wasRunning) {
|
||||
const command = await dependencies.runtime.applyCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) {
|
||||
current = candidate;
|
||||
gatewayAutoPublished = true;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => state);
|
||||
}
|
||||
if (persistEnabled !== undefined) {
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
gatewayAutoEnabled: persistEnabled,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
|
||||
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||
...(wasRunning && runtimeMutationStarted ? [{
|
||||
run: () => dependencies.runtime.restoreRunning(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Gateway auto rollback failed');
|
||||
}
|
||||
|
||||
if (modeChanged) dependencies.onRouteChange(candidate);
|
||||
return current;
|
||||
};
|
||||
|
||||
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
|
||||
const state = dependencies.state.read();
|
||||
const network = state.subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
|
||||
if (!network) {
|
||||
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||
const discoveredState = dependencies.transition.next(current, {
|
||||
network: null,
|
||||
error: discoveryError,
|
||||
});
|
||||
const candidate = dependencies.transition.applyPreference(
|
||||
state.subscriptionUrl
|
||||
? { ...discoveredState, lastError: discoveryError }
|
||||
: discoveredState,
|
||||
state.gatewayAutoEnabled !== false,
|
||||
);
|
||||
return commitCandidate(candidate, { reconfigure });
|
||||
}
|
||||
|
||||
if (
|
||||
current.mode === 'gateway-direct' &&
|
||||
!dependencies.transition.sameRoute(current.gateway, network)
|
||||
) {
|
||||
await commitCandidate(
|
||||
dependencies.transition.next(current, { network }),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
|
||||
let verifiedGateway: VerifiedGateway;
|
||||
try {
|
||||
verifiedGateway = await dependencies.discovery.probeGateway({
|
||||
gateway: network.gateway,
|
||||
subscriptionUrl: String(state.subscriptionUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = errorMessage(error);
|
||||
const latestState = dependencies.state.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||
) {
|
||||
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||
}
|
||||
if (current.lastError !== reason) dependencies.onDiscoveryWarning(reason);
|
||||
return commitCandidate(
|
||||
dependencies.transition.applyPreference(
|
||||
dependencies.transition.next(current, {
|
||||
network: latestNetwork,
|
||||
error: reason,
|
||||
}),
|
||||
latestState.gatewayAutoEnabled !== false,
|
||||
),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
|
||||
const latestState = dependencies.state.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||
) {
|
||||
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||
}
|
||||
return commitCandidate(
|
||||
dependencies.transition.applyPreference(
|
||||
dependencies.transition.next(current, { network: latestNetwork, verifiedGateway }),
|
||||
latestState.gatewayAutoEnabled !== false,
|
||||
),
|
||||
{ reconfigure },
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = (options: RefreshOptions = {}) => {
|
||||
if (dependencies.appMode !== 'client') return Promise.resolve(current);
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = dependencies.serialize(() => runRefresh(options)).finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
const setEnabled = (enabled: boolean) => dependencies.serialize(() => commitCandidate(
|
||||
dependencies.transition.applyPreference(current, enabled),
|
||||
{ persistEnabled: enabled },
|
||||
));
|
||||
|
||||
const startDiscovery = (intervalMs: number) => {
|
||||
if (discoveryTimer) return;
|
||||
discoveryTimer = dependencies.scheduler.setInterval(() => {
|
||||
void refresh().catch(dependencies.onTimerError);
|
||||
}, intervalMs);
|
||||
discoveryTimer.unref();
|
||||
};
|
||||
|
||||
const stopDiscovery = () => {
|
||||
if (!discoveryTimer) return;
|
||||
dependencies.scheduler.clearInterval(discoveryTimer);
|
||||
discoveryTimer = null;
|
||||
};
|
||||
|
||||
return {
|
||||
read: () => current,
|
||||
set: (value: GatewayAutoState) => { current = value; },
|
||||
createInitial: dependencies.transition.createInitial,
|
||||
setEnabled,
|
||||
refresh,
|
||||
startDiscovery,
|
||||
stopDiscovery,
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewayAutoService = ReturnType<typeof createGatewayAutoService>;
|
||||
Reference in New Issue
Block a user