358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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<string, unknown>): 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<RuntimeCommandResult>;
|
|
restoreRunning(): Promise<unknown>;
|
|
stopCommand(): Promise<RuntimeCommandResult>;
|
|
};
|
|
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;
|
|
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<typeof createGatewayAutoService>;
|