import { isDeepStrictEqual } from 'node:util'; import { appliedProfile, desiredProfile, type GatewayAutoState, type StoredState, } from '../../../shared/contracts/state.js'; import { HarborError } from '../../../shared/errors.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): StoredState; }; subscription: { readConfig(profileId: string): 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; restoreRunning(): Promise; stopCommand(): Promise; }; discovery: { readHostNetwork(): HostNetworkState | null; probeGateway(input: { gateway: string; subscriptionUrl: string; }): Promise; }; 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(operation: () => Promise): Promise; 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 | 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; const leavesGatewayDirect = previousGatewayAuto.mode === 'gateway-direct' && candidate.mode !== 'gateway-direct'; if (!stateChanged && persistEnabled === undefined) return current; const previousState = dependencies.state.read(); const wasRunning = modeChanged ? dependencies.runtime.isRunning() : false; const targetProfile = wasRunning ? appliedProfile(previousState) : desiredProfile(previousState); const targetServerId = wasRunning ? previousState.appliedServerId : targetProfile?.desiredServerId || ''; const subscriptionConfig = modeChanged && targetProfile ? dependencies.subscription.readConfig(targetProfile.id) : null; const stopUnavailableTarget = async (cause: unknown) => { let runtimeMutationStarted = false; let gatewayAutoPublished = false; let stateCommitStarted = false; try { const command = await dependencies.runtime.stopCommand(); runtimeMutationStarted = command.mutationStarted; if (!command.ok) throw command.error; current = candidate; gatewayAutoPublished = true; stateCommitStarted = true; dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped', appliedProfileId: '', appliedServerId: '', appliedServerSnapshot: null, ...(persistEnabled === undefined ? {} : { gatewayAutoEnabled: persistEnabled }), })); } catch (error) { await finishRollback(error, [ ...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []), ...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.restoreRunning(), runtime: true, }] : []), ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), ], 'Gateway auto safe-stop rollback failed'); } dependencies.onDiscoveryWarning(errorMessage(cause)); if (modeChanged) dependencies.onRouteChange(candidate); throw cause; }; if (modeChanged && wasRunning && (!targetProfile || !targetServerId || !subscriptionConfig)) { if (leavesGatewayDirect) return stopUnavailableTarget(new HarborError('CONFIG_INVALID')); throw new HarborError('CONFIG_INVALID'); } let candidateConfig: unknown | null = null; if (modeChanged && targetServerId && subscriptionConfig) { try { candidateConfig = dependencies.config.build( subscriptionConfig, targetServerId, previousState.routeRules, candidate, ); } catch (error) { const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; if (wasRunning && leavesGatewayDirect && ['CONFIG_INVALID', 'SERVER_NOT_FOUND'].includes(code)) { return stopUnavailableTarget(error); } throw error; } } const previousConfig = candidateConfig === null ? null : dependencies.config.read(); 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 profile = desiredProfile(state); const subscriptionUrl = profile?.subscriptionUrl || ''; const network = 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( 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, }); } catch (error) { const reason = errorMessage(error); const latestState = dependencies.state.read(); const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || ''; const latestNetwork = latestSubscriptionUrl ? dependencies.discovery.readHostNetwork() : null; if ( latestSubscriptionUrl !== 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 latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || ''; const latestNetwork = latestSubscriptionUrl ? dependencies.discovery.readHostNetwork() : null; if ( latestSubscriptionUrl !== 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;