Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||
import {
|
||||
profileById,
|
||||
type StoredProfile,
|
||||
type StoredState,
|
||||
} from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
@@ -7,11 +11,7 @@ interface ConnectionServiceDependencies {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: {
|
||||
readConfig(): unknown | null;
|
||||
};
|
||||
config: {
|
||||
exists(): boolean;
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
@@ -25,6 +25,7 @@ interface ConnectionServiceDependencies {
|
||||
stopCommand(): Promise<RuntimeCommandResult>;
|
||||
restartCommand(): Promise<RuntimeCommandResult>;
|
||||
};
|
||||
route?: { isGatewayDirect(): boolean };
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
now(): Date;
|
||||
}
|
||||
@@ -46,65 +47,125 @@ export async function captureRuntimeCommand(
|
||||
}
|
||||
}
|
||||
|
||||
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
|
||||
if (expectedRevision === undefined) return;
|
||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfile(state: StoredState, profileId: unknown): StoredProfile {
|
||||
const requested = String(profileId || '').trim();
|
||||
const profile = profileById(state, requested)
|
||||
|| (!requested && state.profiles.length === 1 ? state.profiles[0] : null);
|
||||
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
|
||||
return profile;
|
||||
}
|
||||
|
||||
function withDesiredServer(state: StoredState, profile: StoredProfile, serverId: string) {
|
||||
const nextProfile = { ...profile, desiredServerId: serverId };
|
||||
return {
|
||||
...state,
|
||||
profiles: state.profiles.map((candidate) => candidate.id === profile.id ? nextProfile : candidate),
|
||||
desiredProfileId: profile.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
|
||||
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
const requestedId = String(serverId).trim();
|
||||
const requestedTag = String(selectedTag).trim();
|
||||
const applyWithinQueue = async (
|
||||
previousState: StoredState,
|
||||
profile: StoredProfile,
|
||||
serverIdValue: unknown,
|
||||
selectedTagValue: unknown,
|
||||
) => {
|
||||
const requestedId = String(serverIdValue || '').trim();
|
||||
const requestedTag = String(selectedTagValue || '').trim();
|
||||
const resolvedId = requestedId || (() => {
|
||||
const matches = previousState.servers.filter((server) => server.label === requestedTag);
|
||||
const matches = profile.servers.filter((server) => server.label === requestedTag);
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
})();
|
||||
const selectedServer = previousState.servers.find((server) => server.id === resolvedId);
|
||||
const selectedServer = profile.servers.find((server) => server.id === resolvedId);
|
||||
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||
if (!profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||
|
||||
if (dependencies.route?.isGatewayDirect()) {
|
||||
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
|
||||
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
}
|
||||
|
||||
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
profile.subscriptionConfig,
|
||||
selectedServer.id,
|
||||
previousState.routeRules,
|
||||
);
|
||||
const previousConfig = dependencies.config.read();
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
let desiredCommitStarted = false;
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
desiredCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
selectedServerId: selectedServer.id,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(nextConfig);
|
||||
runtimeMutationStarted = true;
|
||||
await dependencies.runtime.start();
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
...withDesiredServer(state, profile, selectedServer.id),
|
||||
connectionDesired: 'running',
|
||||
appliedProfileId: profile.id,
|
||||
appliedServerId: selectedServer.id,
|
||||
appliedServerSnapshot: selectedServer,
|
||||
appliedAt: dependencies.now().toISOString(),
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
...(configMutationStarted ? [{
|
||||
run: () => previousConfig === null
|
||||
? dependencies.config.remove()
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(configMutationStarted ? [{
|
||||
...(runtimeMutationStarted ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
|
||||
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
};
|
||||
|
||||
const apply = (
|
||||
profileId: unknown,
|
||||
serverId: unknown,
|
||||
selectedTag: unknown = '',
|
||||
expectedRevision?: unknown,
|
||||
) => dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
requireExpectedRevision(state, expectedRevision);
|
||||
return applyWithinQueue(state, resolveProfile(state, profileId), serverId, selectedTag);
|
||||
});
|
||||
|
||||
const activate = (profileId: unknown, expectedRevision?: unknown) => (
|
||||
dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
requireExpectedRevision(state, expectedRevision);
|
||||
const profile = resolveProfile(state, profileId);
|
||||
const selectedServer = profile.servers.find((server) => server.id === profile.desiredServerId);
|
||||
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||
const running = await dependencies.runtime.isRunning();
|
||||
if (!running || dependencies.route?.isGatewayDirect()) {
|
||||
if (state.desiredProfileId !== profile.id) {
|
||||
dependencies.state.update((current) => ({ ...current, desiredProfileId: profile.id }));
|
||||
}
|
||||
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
}
|
||||
return applyWithinQueue(state, profile, selectedServer.id, '');
|
||||
})
|
||||
);
|
||||
|
||||
const stop = () => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
let wasRunning: boolean | null = null;
|
||||
@@ -119,7 +180,13 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' }));
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
connectionDesired: 'stopped',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||
@@ -133,28 +200,53 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
|
||||
|
||||
const restart = () => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID');
|
||||
let wasRunning: boolean | null = null;
|
||||
try {
|
||||
wasRunning = await dependencies.runtime.isRunning();
|
||||
} catch {}
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
const targetProfileId = wasRunning
|
||||
? previousState.appliedProfileId
|
||||
: previousState.desiredProfileId;
|
||||
const targetServerId = wasRunning
|
||||
? previousState.appliedServerId
|
||||
: resolveProfile(previousState, targetProfileId).desiredServerId;
|
||||
const profile = resolveProfile(previousState, targetProfileId);
|
||||
const server = profile.servers.find((candidate) => candidate.id === targetServerId)
|
||||
|| (previousState.appliedServerSnapshot?.id === targetServerId
|
||||
? previousState.appliedServerSnapshot
|
||||
: null);
|
||||
if (!server || !profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||
const candidateConfig = dependencies.config.build(
|
||||
profile.subscriptionConfig,
|
||||
server.id,
|
||||
previousState.routeRules,
|
||||
);
|
||||
const previousConfig = dependencies.config.read();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
const command = await dependencies.runtime.restartCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
appliedServerId: state.selectedServerId,
|
||||
desiredProfileId: wasRunning ? state.desiredProfileId : profile.id,
|
||||
appliedProfileId: profile.id,
|
||||
appliedServerId: server.id,
|
||||
appliedServerSnapshot: server,
|
||||
connectionDesired: 'running',
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||
...(configMutationStarted ? [{
|
||||
run: () => previousConfig === null
|
||||
? dependencies.config.remove()
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(runtimeMutationStarted ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
runtime: true,
|
||||
}] : []),
|
||||
@@ -163,7 +255,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
|
||||
}
|
||||
});
|
||||
|
||||
return { apply, stop, restart };
|
||||
return { apply, activate, stop, restart };
|
||||
}
|
||||
|
||||
export type ConnectionService = ReturnType<typeof createConnectionService>;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
interface DiagnosticServer {
|
||||
id: unknown;
|
||||
label: unknown;
|
||||
}
|
||||
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
|
||||
|
||||
interface DiagnosticState {
|
||||
desiredProfileId?: unknown;
|
||||
appliedProfileId?: unknown;
|
||||
appliedServerId?: unknown;
|
||||
selectedServerId?: unknown;
|
||||
servers?: DiagnosticServer[];
|
||||
appliedServerSnapshot?: HarborServer | null;
|
||||
profiles?: StoredProfile[];
|
||||
}
|
||||
|
||||
interface DiagnosticsResult extends Record<string, unknown> {
|
||||
@@ -25,15 +24,24 @@ function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||
return value as DiagnosticsResult;
|
||||
}
|
||||
|
||||
function selectedServer(state: DiagnosticState) {
|
||||
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
|
||||
const appliedProfile = profiles.find((profile) => profile.id === state.appliedProfileId);
|
||||
const applied = appliedProfile?.servers.find((server) => server.id === state.appliedServerId)
|
||||
|| (state.appliedServerSnapshot?.id === state.appliedServerId
|
||||
? state.appliedServerSnapshot
|
||||
: null);
|
||||
if (state.appliedServerId) return applied;
|
||||
const desiredProfile = profiles.find((profile) => profile.id === state.desiredProfileId);
|
||||
return desiredProfile?.servers.find((server) => server.id === desiredProfile.desiredServerId) || null;
|
||||
}
|
||||
|
||||
export function createConnectivityDiagnosticsUseCase(
|
||||
dependencies: ConnectivityDiagnosticsDependencies,
|
||||
) {
|
||||
return {
|
||||
async run(services: unknown, target: unknown) {
|
||||
const state = dependencies.readState();
|
||||
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||
const selected = (Array.isArray(state.servers) ? state.servers : [])
|
||||
.find((server) => server.id === appliedServerId);
|
||||
const selected = selectedServer(dependencies.readState());
|
||||
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
|
||||
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';
|
||||
|
||||
@@ -25,7 +31,7 @@ interface GatewayAutoServiceDependencies {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: { readConfig(): unknown | null };
|
||||
subscription: { readConfig(profileId: string): unknown | null };
|
||||
config: {
|
||||
build(
|
||||
subscriptionConfig: unknown,
|
||||
@@ -42,6 +48,7 @@ interface GatewayAutoServiceDependencies {
|
||||
isRunning(): boolean;
|
||||
applyCommand(): Promise<RuntimeCommandResult>;
|
||||
restoreRunning(): Promise<unknown>;
|
||||
stopCommand(): Promise<RuntimeCommandResult>;
|
||||
};
|
||||
discovery: {
|
||||
readHostNetwork(): HostNetworkState | null;
|
||||
@@ -108,22 +115,76 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
|
||||
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 subscriptionConfig = modeChanged
|
||||
? dependencies.subscription.readConfig()
|
||||
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 candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
|
||||
? dependencies.config.build(
|
||||
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,
|
||||
previousState.selectedServerId,
|
||||
targetServerId,
|
||||
previousState.routeRules,
|
||||
candidate,
|
||||
)
|
||||
: null;
|
||||
);
|
||||
} 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();
|
||||
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let gatewayAutoPublished = false;
|
||||
@@ -171,7 +232,9 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
|
||||
|
||||
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
|
||||
const state = dependencies.state.read();
|
||||
const network = state.subscriptionUrl
|
||||
const profile = desiredProfile(state);
|
||||
const subscriptionUrl = profile?.subscriptionUrl || '';
|
||||
const network = subscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
|
||||
@@ -182,7 +245,7 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
|
||||
error: discoveryError,
|
||||
});
|
||||
const candidate = dependencies.transition.applyPreference(
|
||||
state.subscriptionUrl
|
||||
subscriptionUrl
|
||||
? { ...discoveredState, lastError: discoveryError }
|
||||
: discoveredState,
|
||||
state.gatewayAutoEnabled !== false,
|
||||
@@ -204,16 +267,17 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
|
||||
try {
|
||||
verifiedGateway = await dependencies.discovery.probeGateway({
|
||||
gateway: network.gateway,
|
||||
subscriptionUrl: String(state.subscriptionUrl),
|
||||
subscriptionUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = errorMessage(error);
|
||||
const latestState = dependencies.state.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
|
||||
const latestNetwork = latestSubscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
latestSubscriptionUrl !== subscriptionUrl ||
|
||||
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||
) {
|
||||
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||
@@ -232,11 +296,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
|
||||
}
|
||||
|
||||
const latestState = dependencies.state.read();
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
|
||||
const latestNetwork = latestSubscriptionUrl
|
||||
? dependencies.discovery.readHostNetwork()
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
latestSubscriptionUrl !== subscriptionUrl ||
|
||||
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||
) {
|
||||
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { RouteRule, StoredState } from '../../../shared/contracts/state.js';
|
||||
import {
|
||||
appliedProfile,
|
||||
desiredProfile,
|
||||
type RouteRule,
|
||||
type StoredState,
|
||||
} from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { normalizeRouteRules } from '../../../shared/routingRules.js';
|
||||
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||
@@ -11,7 +16,7 @@ interface RouteRulesDependencies {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: { readConfig(): unknown | null };
|
||||
subscription: { readConfig(profileId: string): unknown | null };
|
||||
config: {
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
||||
read(): string | null;
|
||||
@@ -30,8 +35,17 @@ interface RouteRulesDependencies {
|
||||
|
||||
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
|
||||
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||
if (!previousState.selectedServerId || !subscriptionConfig) {
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
const targetProfile = wasRunning
|
||||
? appliedProfile(previousState)
|
||||
: desiredProfile(previousState);
|
||||
const targetServerId = wasRunning
|
||||
? previousState.appliedServerId
|
||||
: targetProfile?.desiredServerId || '';
|
||||
const subscriptionConfig = targetProfile
|
||||
? dependencies.subscription.readConfig(targetProfile.id)
|
||||
: null;
|
||||
if (!targetServerId || !subscriptionConfig) {
|
||||
let stateCommitStarted = false;
|
||||
try {
|
||||
stateCommitStarted = true;
|
||||
@@ -50,11 +64,10 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||
|
||||
const candidateConfig = dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
previousState.selectedServerId,
|
||||
targetServerId,
|
||||
routeRules,
|
||||
);
|
||||
const previousConfig = dependencies.config.read();
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HarborServer } from '../../../shared/contracts/state.js';
|
||||
import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
|
||||
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||
@@ -36,19 +37,23 @@ export async function checkServerHealth(
|
||||
}
|
||||
|
||||
interface ServerHealthDependencies {
|
||||
readServers(): HarborServer[];
|
||||
readProfiles(): StoredProfile[];
|
||||
readDesiredProfileId(): string;
|
||||
ping: Ping;
|
||||
}
|
||||
|
||||
export function createServerHealthService(dependencies: ServerHealthDependencies) {
|
||||
return {
|
||||
check(serverIds: unknown) {
|
||||
check(profileIdValue: unknown, serverIds: unknown) {
|
||||
const requestedProfileId = String(profileIdValue || '').trim();
|
||||
const profileId = requestedProfileId || dependencies.readDesiredProfileId();
|
||||
const profile = dependencies.readProfiles().find((candidate) => candidate.id === profileId);
|
||||
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
|
||||
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||
const servers = dependencies.readServers();
|
||||
return checkServerHealth(
|
||||
requestedIds.size
|
||||
? servers.filter((server) => requestedIds.has(server.id))
|
||||
: servers,
|
||||
? profile.servers.filter((server) => requestedIds.has(server.id))
|
||||
: profile.servers,
|
||||
dependencies.ping,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type {
|
||||
GatewayAutoState,
|
||||
HarborServer,
|
||||
StoredState,
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import {
|
||||
profileById,
|
||||
type GatewayAutoState,
|
||||
type HarborServer,
|
||||
type StoredProfile,
|
||||
type StoredState,
|
||||
} from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface ParsedSubscription {
|
||||
config: unknown;
|
||||
sourceConfig?: unknown;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
fetchedAt: string;
|
||||
@@ -29,11 +32,6 @@ interface SubscriptionServiceDependencies {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
cache: {
|
||||
read(): unknown;
|
||||
write(value: unknown): void;
|
||||
remove(): void;
|
||||
};
|
||||
config: {
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||
read(): string | null;
|
||||
@@ -57,216 +55,385 @@ interface SubscriptionServiceDependencies {
|
||||
clearInterval(handle: TimerHandle): void;
|
||||
};
|
||||
onRefreshError(error: unknown): void;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
interface ResetOptions {
|
||||
stopRuntime?: boolean;
|
||||
expectedSubscription?: {
|
||||
url: string;
|
||||
generation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscriptionMutationResult extends Record<string, unknown> {
|
||||
export interface ProfileMutationResult extends Record<string, unknown> {
|
||||
success: true;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
fetchedAt: string;
|
||||
selectedServerId: string;
|
||||
selectedTag: string;
|
||||
profileId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||
'SUBSCRIPTION_EXPIRED',
|
||||
'SUBSCRIPTION_DISABLED',
|
||||
'SUBSCRIPTION_REJECTED',
|
||||
]);
|
||||
const safeErrorCode = (error: unknown) => (
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: 'UNKNOWN'
|
||||
);
|
||||
|
||||
const cleanLabel = (value: unknown) => String(value || '').trim();
|
||||
const foldedLabel = (value: unknown) => cleanLabel(value).toLocaleLowerCase('ru');
|
||||
|
||||
function requireLabel(value: unknown) {
|
||||
const label = cleanLabel(value);
|
||||
if (!label || label.length > 64) throw new HarborError('REQUEST_INVALID');
|
||||
return label;
|
||||
}
|
||||
|
||||
function requireExpectedRevision(state: StoredState, expectedRevision: unknown) {
|
||||
if (expectedRevision === undefined) return;
|
||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
}
|
||||
|
||||
function requireProfile(state: StoredState, profileId: unknown) {
|
||||
const profile = profileById(state, profileId);
|
||||
if (!profile) throw new HarborError('PROFILE_NOT_FOUND');
|
||||
return profile;
|
||||
}
|
||||
|
||||
function assertUniqueLabel(state: StoredState, label: string, exceptProfileId = '') {
|
||||
if (state.profiles.some((profile) => (
|
||||
profile.id !== exceptProfileId && foldedLabel(profile.label) === foldedLabel(label)
|
||||
))) throw new HarborError('PROFILE_NAME_CONFLICT');
|
||||
}
|
||||
|
||||
function replaceProfile(state: StoredState, nextProfile: StoredProfile) {
|
||||
return state.profiles.map((profile) => profile.id === nextProfile.id ? nextProfile : profile);
|
||||
}
|
||||
|
||||
function mutationResult(profile: Pick<StoredProfile, 'id' | 'label'>): ProfileMutationResult {
|
||||
return { success: true, profileId: profile.id, label: profile.label };
|
||||
}
|
||||
|
||||
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
|
||||
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
|
||||
const refreshPromises = new Map<string, Promise<ProfileMutationResult>>();
|
||||
let refreshTimer: TimerHandle | null = null;
|
||||
let subscriptionGeneration = 0;
|
||||
|
||||
const restoreCache = (previous: unknown) => {
|
||||
if (previous !== null) dependencies.cache.write(previous);
|
||||
else dependencies.cache.remove();
|
||||
};
|
||||
const now = dependencies.now || (() => new Date());
|
||||
|
||||
const restoreConfig = (previous: string | null) => {
|
||||
if (previous === null) dependencies.config.remove();
|
||||
else dependencies.config.restore(previous);
|
||||
};
|
||||
|
||||
const commitSubscription = (
|
||||
const preflightAddProfile = (labelValue: unknown, expectedRevision?: unknown) => {
|
||||
const state = dependencies.state.read();
|
||||
requireExpectedRevision(state, expectedRevision);
|
||||
assertUniqueLabel(state, requireLabel(labelValue));
|
||||
};
|
||||
|
||||
const preflightRenameProfile = (
|
||||
profileId: unknown,
|
||||
labelValue: unknown,
|
||||
expectedRevision?: unknown,
|
||||
) => {
|
||||
const state = dependencies.state.read();
|
||||
requireExpectedRevision(state, expectedRevision);
|
||||
const profile = requireProfile(state, profileId);
|
||||
assertUniqueLabel(state, requireLabel(labelValue), profile.id);
|
||||
};
|
||||
|
||||
const addProfile = async (
|
||||
labelValue: unknown,
|
||||
subscriptionUrlValue: unknown,
|
||||
expectedRevision?: unknown,
|
||||
) => {
|
||||
const label = requireLabel(labelValue);
|
||||
const subscriptionUrl = String(subscriptionUrlValue || '').trim();
|
||||
const preflight = dependencies.state.read();
|
||||
requireExpectedRevision(preflight, expectedRevision);
|
||||
assertUniqueLabel(preflight, label);
|
||||
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||
|
||||
// Admission CAS already passed; background freshness may advance the global revision during provider I/O.
|
||||
return dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
assertUniqueLabel(state, label);
|
||||
const profile: StoredProfile = {
|
||||
id: `profile_${crypto.randomUUID()}`,
|
||||
label,
|
||||
subscriptionUrl,
|
||||
subscriptionConfig: parsed.config,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
desiredServerId: '',
|
||||
lastRefreshAttemptAt: parsed.fetchedAt,
|
||||
lastRefreshErrorCode: null,
|
||||
};
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: [...current.profiles, profile],
|
||||
desiredProfileId: current.profiles.length ? current.desiredProfileId : profile.id,
|
||||
}));
|
||||
return mutationResult(profile);
|
||||
});
|
||||
};
|
||||
|
||||
const renameProfile = (profileId: unknown, labelValue: unknown, expectedRevision?: unknown) => (
|
||||
dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
requireExpectedRevision(state, expectedRevision);
|
||||
const profile = requireProfile(state, profileId);
|
||||
const label = requireLabel(labelValue);
|
||||
if (profile.label === label) return mutationResult(profile);
|
||||
assertUniqueLabel(state, label, profile.id);
|
||||
const renamed = { ...profile, label };
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, renamed),
|
||||
}));
|
||||
return mutationResult(renamed);
|
||||
})
|
||||
);
|
||||
|
||||
const selectProfileServer = (
|
||||
profileId: unknown,
|
||||
serverIdValue: unknown,
|
||||
expectedRevision?: unknown,
|
||||
) => dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
requireExpectedRevision(state, expectedRevision);
|
||||
const profile = requireProfile(state, profileId);
|
||||
const serverId = String(serverIdValue || '').trim();
|
||||
if (!profile.servers.some((server) => server.id === serverId)) {
|
||||
throw new HarborError('SERVER_NOT_FOUND');
|
||||
}
|
||||
if (profile.desiredServerId === serverId) return mutationResult(profile);
|
||||
const selected = { ...profile, desiredServerId: serverId };
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, selected),
|
||||
}));
|
||||
return mutationResult(selected);
|
||||
});
|
||||
|
||||
const recordRefreshError = async (
|
||||
profileId: string,
|
||||
subscriptionUrl: string,
|
||||
error: unknown,
|
||||
) => dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
const profile = requireProfile(state, profileId);
|
||||
if (profile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
|
||||
const failed = {
|
||||
...profile,
|
||||
lastRefreshAttemptAt: now().toISOString(),
|
||||
lastRefreshErrorCode: safeErrorCode(error),
|
||||
};
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, failed),
|
||||
}));
|
||||
});
|
||||
|
||||
const commitRefresh = (
|
||||
profileId: string,
|
||||
subscriptionUrl: string,
|
||||
parsed: ParsedSubscription,
|
||||
{ resetSelection = false, expectedGeneration }: {
|
||||
resetSelection?: boolean;
|
||||
expectedGeneration?: number;
|
||||
} = {},
|
||||
) => dependencies.serialize(async () => {
|
||||
// Re-read the profile after provider I/O and guard its owner instead of rejecting background-only revisions.
|
||||
const previousState = dependencies.state.read();
|
||||
if (
|
||||
subscriptionGeneration !== expectedGeneration ||
|
||||
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
|
||||
) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
const previousProfile = requireProfile(previousState, profileId);
|
||||
if (previousProfile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
|
||||
|
||||
const selectedServerId = resetSelection
|
||||
? ''
|
||||
: dependencies.provider.selectRefreshedServer(
|
||||
previousState.selectedServerId,
|
||||
previousState.servers,
|
||||
parsed.servers,
|
||||
);
|
||||
const candidateConfig = selectedServerId
|
||||
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
|
||||
: null;
|
||||
const previousCache = dependencies.cache.read();
|
||||
const previousConfig = dependencies.config.read();
|
||||
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
let restoreRuntime = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if ((resetSelection || !candidateConfig) && wasRunning) {
|
||||
restoreRuntime = true;
|
||||
await dependencies.runtime.stop();
|
||||
}
|
||||
if (candidateConfig) dependencies.config.write(candidateConfig);
|
||||
else dependencies.config.remove();
|
||||
dependencies.cache.write({
|
||||
url: subscriptionUrl,
|
||||
config: parsed.sourceConfig || parsed.config,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
});
|
||||
if (!resetSelection && wasRunning && candidateConfig) {
|
||||
restoreRuntime = true;
|
||||
await dependencies.runtime.start();
|
||||
}
|
||||
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...(resetSelection ? {
|
||||
routeRules: state.routeRules,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
connectionDesired: 'stopped',
|
||||
} : state),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedServerId,
|
||||
appliedServerId: selectedServerId,
|
||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
subscriptionGeneration += 1;
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||
{ run: () => restoreCache(previousCache) },
|
||||
{ run: () => restoreConfig(previousConfig) },
|
||||
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||
], 'Subscription rollback failed');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
const desiredServerId = dependencies.provider.selectRefreshedServer(
|
||||
previousProfile.desiredServerId,
|
||||
previousProfile.servers,
|
||||
parsed.servers,
|
||||
);
|
||||
const refreshedProfile: StoredProfile = {
|
||||
...previousProfile,
|
||||
subscriptionConfig: parsed.config,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedServerId,
|
||||
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
||||
desiredServerId,
|
||||
lastRefreshAttemptAt: parsed.fetchedAt,
|
||||
lastRefreshErrorCode: null,
|
||||
};
|
||||
const running = await dependencies.runtime.isRunning();
|
||||
const refreshesApplied = running && previousState.appliedProfileId === profileId;
|
||||
const nextAppliedServerId = refreshesApplied
|
||||
? dependencies.provider.selectRefreshedServer(
|
||||
previousState.appliedServerId,
|
||||
previousProfile.servers,
|
||||
parsed.servers,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (!refreshesApplied || !nextAppliedServerId) {
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, refreshedProfile),
|
||||
}));
|
||||
return mutationResult(refreshedProfile);
|
||||
}
|
||||
|
||||
const nextAppliedServer = parsed.servers.find((server) => server.id === nextAppliedServerId)!;
|
||||
const candidateConfig = dependencies.config.build(
|
||||
parsed.config,
|
||||
nextAppliedServerId,
|
||||
previousState.routeRules,
|
||||
);
|
||||
const previousConfig = dependencies.config.read();
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
runtimeMutationStarted = true;
|
||||
await dependencies.runtime.start();
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, refreshedProfile),
|
||||
appliedServerId: nextAppliedServerId,
|
||||
appliedServerSnapshot: nextAppliedServer,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||
...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||
], 'Subscription refresh rollback failed');
|
||||
}
|
||||
return mutationResult(refreshedProfile);
|
||||
});
|
||||
|
||||
const importSubscription = async (subscriptionUrl: string) => {
|
||||
const expectedGeneration = subscriptionGeneration;
|
||||
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration });
|
||||
const refreshProfile = (profileIdValue: unknown, expectedRevision?: unknown) => {
|
||||
const profileId = String(profileIdValue || '').trim();
|
||||
const existing = refreshPromises.get(profileId);
|
||||
if (existing) return existing;
|
||||
const initialState = dependencies.state.read();
|
||||
requireExpectedRevision(initialState, expectedRevision);
|
||||
const initialProfile = requireProfile(initialState, profileId);
|
||||
const operation = (async () => {
|
||||
let parsed: ParsedSubscription;
|
||||
try {
|
||||
parsed = await dependencies.provider.fetchSubscription(initialProfile.subscriptionUrl);
|
||||
} catch (error) {
|
||||
if (safeErrorCode(error) !== 'STATE_CONFLICT') {
|
||||
await recordRefreshError(
|
||||
profileId,
|
||||
initialProfile.subscriptionUrl,
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return commitRefresh(
|
||||
profileId,
|
||||
initialProfile.subscriptionUrl,
|
||||
parsed,
|
||||
);
|
||||
})().finally(() => refreshPromises.delete(profileId));
|
||||
refreshPromises.set(profileId, operation);
|
||||
return operation;
|
||||
};
|
||||
|
||||
const deleteProfile = (
|
||||
profileIdValue: unknown,
|
||||
modeValue: unknown = 'delete',
|
||||
expectedRevision?: unknown,
|
||||
) => dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
requireExpectedRevision(previousState, expectedRevision);
|
||||
const profile = requireProfile(previousState, profileIdValue);
|
||||
const mode = String(modeValue || 'delete');
|
||||
if (!['delete', 'stop-and-delete'].includes(mode)) throw new HarborError('REQUEST_INVALID');
|
||||
const running = await dependencies.runtime.isRunning();
|
||||
const applied = running && previousState.appliedProfileId === profile.id;
|
||||
if (applied && mode !== 'stop-and-delete') throw new HarborError('PROFILE_IN_USE');
|
||||
|
||||
const previousConfig = dependencies.config.read();
|
||||
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||
const removesAppliedTarget = previousState.appliedProfileId === profile.id;
|
||||
let runtimeMutationStarted = false;
|
||||
let configMutationStarted = false;
|
||||
let gatewayMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if (applied) {
|
||||
runtimeMutationStarted = true;
|
||||
await dependencies.runtime.stop();
|
||||
}
|
||||
if (removesAppliedTarget) {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.remove();
|
||||
}
|
||||
if (
|
||||
previousState.desiredProfileId === profile.id
|
||||
&& !(running && previousState.appliedProfileId !== profile.id)
|
||||
) {
|
||||
gatewayMutationStarted = true;
|
||||
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||
}
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: current.profiles.filter((candidate) => candidate.id !== profile.id),
|
||||
desiredProfileId: current.desiredProfileId === profile.id ? '' : current.desiredProfileId,
|
||||
appliedProfileId: current.appliedProfileId === profile.id ? '' : current.appliedProfileId,
|
||||
appliedServerId: current.appliedProfileId === profile.id ? '' : current.appliedServerId,
|
||||
appliedServerSnapshot: current.appliedProfileId === profile.id
|
||||
? null
|
||||
: current.appliedServerSnapshot,
|
||||
...(current.appliedProfileId === profile.id ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
...(gatewayMutationStarted ? [{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }] : []),
|
||||
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||
...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||
], 'Subscription delete rollback failed');
|
||||
}
|
||||
return mutationResult(profile);
|
||||
});
|
||||
|
||||
// One-release compatibility for the old single-subscription client.
|
||||
const importSubscription = (subscriptionUrl: string, expectedRevision?: unknown) => {
|
||||
const state = dependencies.state.read();
|
||||
if (state.profiles.length) throw new HarborError('STATE_CONFLICT');
|
||||
return addProfile('Основной', subscriptionUrl, expectedRevision);
|
||||
};
|
||||
|
||||
const refreshSavedSubscription = (expectedRevision?: unknown) => {
|
||||
const state = dependencies.state.read();
|
||||
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
|
||||
return refreshProfile(state.profiles[0].id, expectedRevision);
|
||||
};
|
||||
|
||||
const resetSavedSubscription = ({
|
||||
stopRuntime = true,
|
||||
expectedSubscription,
|
||||
}: ResetOptions = {}) => (
|
||||
dependencies.serialize(async () => {
|
||||
const previousState = dependencies.state.read();
|
||||
if (expectedSubscription && (
|
||||
previousState.subscriptionUrl !== expectedSubscription.url ||
|
||||
subscriptionGeneration !== expectedSubscription.generation
|
||||
)) return false;
|
||||
const previousCache = dependencies.cache.read();
|
||||
const previousConfig = dependencies.config.read();
|
||||
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
|
||||
let restoreRuntime = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
if (stopRuntime) {
|
||||
restoreRuntime = wasRunning;
|
||||
await dependencies.runtime.stop();
|
||||
}
|
||||
dependencies.config.remove();
|
||||
dependencies.cache.remove();
|
||||
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
|
||||
subscriptionGeneration += 1;
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||
{ run: () => restoreCache(previousCache) },
|
||||
{ run: () => restoreConfig(previousConfig) },
|
||||
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||
], 'Subscription rollback failed');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
const refreshSavedSubscription = () => {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
|
||||
const expectedGeneration = subscriptionGeneration;
|
||||
const operation = (async () => {
|
||||
try {
|
||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: '';
|
||||
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
|
||||
const reset = await resetSavedSubscription({
|
||||
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
|
||||
});
|
||||
if (!reset) throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
refreshPromise = operation;
|
||||
return operation;
|
||||
expectedRevision,
|
||||
}: { stopRuntime?: boolean; expectedRevision?: unknown } = {}) => {
|
||||
const state = dependencies.state.read();
|
||||
if (!state.profiles.length) return Promise.resolve(false);
|
||||
if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
|
||||
return deleteProfile(
|
||||
state.profiles[0].id,
|
||||
stopRuntime ? 'stop-and-delete' : 'delete',
|
||||
expectedRevision,
|
||||
).then(() => true);
|
||||
};
|
||||
|
||||
const startAutoRefresh = (intervalMs: number) => {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = dependencies.scheduler.setInterval(() => {
|
||||
if (!dependencies.state.read().subscriptionUrl) return;
|
||||
void refreshSavedSubscription().catch(dependencies.onRefreshError);
|
||||
void (async () => {
|
||||
for (const { id } of dependencies.state.read().profiles) {
|
||||
try {
|
||||
await refreshProfile(id);
|
||||
} catch (error) {
|
||||
dependencies.onRefreshError(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, intervalMs);
|
||||
refreshTimer.unref();
|
||||
};
|
||||
@@ -278,6 +445,13 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
|
||||
};
|
||||
|
||||
return {
|
||||
preflightAddProfile,
|
||||
preflightRenameProfile,
|
||||
addProfile,
|
||||
renameProfile,
|
||||
selectProfileServer,
|
||||
refreshProfile,
|
||||
deleteProfile,
|
||||
importSubscription,
|
||||
refreshSavedSubscription,
|
||||
resetSavedSubscription,
|
||||
|
||||
@@ -5,18 +5,33 @@ import type { ConnectionService } from '../../features/connection/index.js';
|
||||
interface ServerApplyRouteDependencies {
|
||||
connection: Pick<ConnectionService, 'apply'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
|
||||
withOperation<T>(
|
||||
kind: string,
|
||||
operation: (operationRevision: number) => Promise<T>,
|
||||
options?: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown },
|
||||
): Promise<T>;
|
||||
sendState(res: ServerResponse, extra: { profileId: string; serverId: string; selectedTag: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
|
||||
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
|
||||
const {
|
||||
profileId = '',
|
||||
serverId = '',
|
||||
selectedTag = '',
|
||||
expectedRevision,
|
||||
} = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'apply-server',
|
||||
() => dependencies.connection.apply(serverId, selectedTag),
|
||||
(operationRevision) => dependencies.connection.apply(
|
||||
profileId,
|
||||
serverId,
|
||||
selectedTag,
|
||||
operationRevision,
|
||||
),
|
||||
{ expectedRevision, profileId, serverId },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
|
||||
@@ -5,16 +5,22 @@ import type { ServerHealthService } from '../../features/servers/index.js';
|
||||
interface ServerHealthRouteDependencies {
|
||||
serverHealth: ServerHealthService;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
|
||||
sendState(res: ServerResponse, extra: {
|
||||
profileId: string;
|
||||
results: Array<Record<string, unknown>>;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/servers\/ping$/);
|
||||
if (req.method !== 'POST' || (!profileMatch && pathname !== '/api/servers/ping-all')) return false;
|
||||
const { serverIds = [] } = await dependencies.readBody(req);
|
||||
const results = await dependencies.serverHealth.check(serverIds);
|
||||
await dependencies.sendState(res, { results });
|
||||
const profileId = profileMatch ? decodeURIComponent(profileMatch[1]) : '';
|
||||
const results = await dependencies.serverHealth.check(profileId, serverIds);
|
||||
await dependencies.sendState(res, { profileId, results });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ function withStateV0Compatibility(
|
||||
singboxRunning: snapshot.connection.process === 'running',
|
||||
singboxStartedAt: snapshot.connection.startedAt,
|
||||
subscriptionHost: snapshot.subscription.host,
|
||||
hasSubscription: snapshot.subscription.status === 'ready',
|
||||
hasSubscription: snapshot.subscription.status !== 'missing',
|
||||
selectedTag: stored.selectedTag,
|
||||
userInfo: snapshot.subscription.userInfo,
|
||||
fetchedAt: snapshot.subscription.fetchedAt,
|
||||
|
||||
@@ -1,43 +1,170 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { ConnectionService } from '../../features/connection/index.js';
|
||||
import type { SubscriptionService } from '../../features/subscription/index.js';
|
||||
|
||||
interface OperationOptions {
|
||||
expectedRevision?: unknown;
|
||||
profileId?: unknown;
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
interface SubscriptionMutationRouteDependencies {
|
||||
subscriptionService: Pick<
|
||||
SubscriptionService,
|
||||
'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription'
|
||||
| 'preflightAddProfile'
|
||||
| 'preflightRenameProfile'
|
||||
| 'addProfile'
|
||||
| 'renameProfile'
|
||||
| 'selectProfileServer'
|
||||
| 'refreshProfile'
|
||||
| 'deleteProfile'
|
||||
| 'importSubscription'
|
||||
| 'refreshSavedSubscription'
|
||||
| 'resetSavedSubscription'
|
||||
>;
|
||||
connection: Pick<ConnectionService, 'activate'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
withOperation<T>(
|
||||
kind: string,
|
||||
operation: (operationRevision: number) => Promise<T>,
|
||||
options?: OperationOptions,
|
||||
): Promise<T>;
|
||||
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)$/);
|
||||
const serverMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/server$/);
|
||||
const activateMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/activate$/);
|
||||
const refreshMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/refresh$/);
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/profiles') {
|
||||
const { label = '', url = '', expectedRevision } = await dependencies.readBody(req);
|
||||
dependencies.subscriptionService.preflightAddProfile(label, expectedRevision);
|
||||
const result = await dependencies.withOperation(
|
||||
'profile-add',
|
||||
(operationRevision) => dependencies.subscriptionService.addProfile(
|
||||
label,
|
||||
url,
|
||||
operationRevision,
|
||||
),
|
||||
{ expectedRevision },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH' && profileMatch) {
|
||||
const profileId = decodeURIComponent(profileMatch[1]);
|
||||
const { label = '', expectedRevision } = await dependencies.readBody(req);
|
||||
dependencies.subscriptionService.preflightRenameProfile(profileId, label, expectedRevision);
|
||||
const result = await dependencies.withOperation(
|
||||
'profile-rename',
|
||||
(operationRevision) => dependencies.subscriptionService.renameProfile(
|
||||
profileId,
|
||||
label,
|
||||
operationRevision,
|
||||
),
|
||||
{ expectedRevision, profileId },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'PUT' && serverMatch) {
|
||||
const profileId = decodeURIComponent(serverMatch[1]);
|
||||
const { serverId = '', expectedRevision } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'profile-select-server',
|
||||
(operationRevision) => dependencies.subscriptionService.selectProfileServer(
|
||||
profileId,
|
||||
serverId,
|
||||
operationRevision,
|
||||
),
|
||||
{ expectedRevision, profileId, serverId },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && activateMatch) {
|
||||
const profileId = decodeURIComponent(activateMatch[1]);
|
||||
const { expectedRevision } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'profile-activate',
|
||||
(operationRevision) => dependencies.connection.activate(profileId, operationRevision),
|
||||
{ expectedRevision, profileId },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && refreshMatch) {
|
||||
const profileId = decodeURIComponent(refreshMatch[1]);
|
||||
const { expectedRevision } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'profile-refresh',
|
||||
(operationRevision) => dependencies.subscriptionService.refreshProfile(
|
||||
profileId,
|
||||
operationRevision,
|
||||
),
|
||||
{ expectedRevision, profileId },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && profileMatch) {
|
||||
const profileId = decodeURIComponent(profileMatch[1]);
|
||||
const { mode = 'delete', expectedRevision } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'profile-delete',
|
||||
(operationRevision) => dependencies.subscriptionService.deleteProfile(
|
||||
profileId,
|
||||
mode,
|
||||
operationRevision,
|
||||
),
|
||||
{ expectedRevision, profileId },
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
// One-release compatibility for the old single-subscription client.
|
||||
if (req.method === 'POST' && pathname === '/api/subscription/fetch') {
|
||||
const { url = '' } = await dependencies.readBody(req);
|
||||
const result = await dependencies.withOperation(
|
||||
'subscription-import',
|
||||
() => dependencies.subscriptionService.importSubscription(String(url).trim()),
|
||||
(operationRevision) => dependencies.subscriptionService.importSubscription(
|
||||
String(url).trim(),
|
||||
operationRevision,
|
||||
),
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||
const { success: _success, ...result } = await dependencies.withOperation(
|
||||
if (req.method === 'POST' && pathname === '/api/subscription/refresh') {
|
||||
const result = await dependencies.withOperation(
|
||||
'subscription-refresh',
|
||||
() => dependencies.subscriptionService.refreshSavedSubscription(),
|
||||
(operationRevision) => dependencies.subscriptionService.refreshSavedSubscription(
|
||||
operationRevision,
|
||||
),
|
||||
);
|
||||
await dependencies.sendState(res, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
if (req.method === 'DELETE' && pathname === '/api/subscription') {
|
||||
await dependencies.withOperation(
|
||||
'subscription-forget',
|
||||
() => dependencies.subscriptionService.resetSavedSubscription(),
|
||||
(operationRevision) => dependencies.subscriptionService.resetSavedSubscription({
|
||||
expectedRevision: operationRevision,
|
||||
}),
|
||||
);
|
||||
await dependencies.sendState(res);
|
||||
return true;
|
||||
|
||||
+279
-86
@@ -28,13 +28,19 @@ import {
|
||||
selectRefreshedServer,
|
||||
} from './subscription.js';
|
||||
import {
|
||||
desiredProfile,
|
||||
normalizeStoredState,
|
||||
type OperationState,
|
||||
type RouteRule,
|
||||
type StoredState,
|
||||
} from '../shared/contracts/state.js';
|
||||
import { serverIdentityKey } from '../shared/serverIdentity.js';
|
||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||
import {
|
||||
atomicWriteFile,
|
||||
createJsonStore,
|
||||
createStateStore,
|
||||
} from './services/stateStore.js';
|
||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||
import {
|
||||
createDeviceInventoryService,
|
||||
@@ -93,11 +99,49 @@ function errorMessage(error: unknown) {
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
const stateStore = createStateStore(settings.statePath);
|
||||
const stateFileExisted = fs.existsSync(settings.statePath);
|
||||
const legacyStateBytes = stateFileExisted
|
||||
? fs.readFileSync(settings.statePath, 'utf8')
|
||||
: null;
|
||||
let legacyStateRecord: Record<string, unknown> = {};
|
||||
try {
|
||||
legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes));
|
||||
} catch {}
|
||||
const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion)
|
||||
? Number(legacyStateRecord.schemaVersion)
|
||||
: 0;
|
||||
const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath)
|
||||
? fs.readFileSync(settings.subscriptionCachePath, 'utf8')
|
||||
: null;
|
||||
const subscriptionCacheStore = createJsonStore({
|
||||
filePath: settings.subscriptionCachePath,
|
||||
defaultValue: null,
|
||||
});
|
||||
const rawLegacySubscriptionCache = subscriptionCacheStore.read();
|
||||
const legacyCacheRecord = record(rawLegacySubscriptionCache);
|
||||
const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim();
|
||||
const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim();
|
||||
const legacyCacheOwnerMismatch = legacyStateVersion < 5
|
||||
&& Boolean(legacyCacheRecord.config)
|
||||
&& (legacyStateSubscriptionUrl
|
||||
? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl
|
||||
: !legacyCacheSubscriptionUrl);
|
||||
let legacySubscriptionCache = rawLegacySubscriptionCache;
|
||||
let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery);
|
||||
if (legacyCacheOwnerMismatch) {
|
||||
legacySubscriptionCache = null;
|
||||
} else if (legacyCacheRecord.config) {
|
||||
try {
|
||||
legacySubscriptionCache = {
|
||||
...legacyCacheRecord,
|
||||
...normalizeSubscriptionConfig(legacyCacheRecord.config),
|
||||
};
|
||||
} catch {
|
||||
legacySubscriptionCache = null;
|
||||
legacySubscriptionCacheRejected = true;
|
||||
}
|
||||
}
|
||||
const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache });
|
||||
const deviceStore = createJsonStore<InventoryState>({
|
||||
filePath: settings.deviceStatePath,
|
||||
defaultValue: migrateDeviceInventoryState({}),
|
||||
@@ -112,32 +156,77 @@ if (deviceStore.migration) {
|
||||
if (deviceStore.recovery) {
|
||||
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
|
||||
}
|
||||
let cacheRecoveryLogged = false;
|
||||
|
||||
function readRawSubscriptionCache() {
|
||||
const cached = subscriptionCacheStore.read();
|
||||
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
||||
cacheRecoveryLogged = true;
|
||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function readSubscriptionCache() {
|
||||
const raw = readRawSubscriptionCache();
|
||||
const cached = record(raw);
|
||||
return cached.config
|
||||
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
|
||||
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
|
||||
}
|
||||
|
||||
const initialStoredState = stateStore.read();
|
||||
let initialStoredState = stateStore.read();
|
||||
if (stateStore.migration) {
|
||||
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
||||
}
|
||||
if (stateStore.recovery) {
|
||||
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
||||
}
|
||||
if (subscriptionCacheStore.recovery) {
|
||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||
}
|
||||
const rejectedLegacyMigration = legacySubscriptionCacheRejected
|
||||
&& (
|
||||
!stateFileExisted
|
||||
|| Boolean(stateStore.recovery)
|
||||
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
|
||||
);
|
||||
const mismatchedLegacyMigration = legacyCacheOwnerMismatch
|
||||
&& (
|
||||
!stateFileExisted
|
||||
|| Boolean(stateStore.recovery)
|
||||
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
|
||||
);
|
||||
if (rejectedLegacyMigration) {
|
||||
initialStoredState = stateStore.update((state) => ({
|
||||
...state,
|
||||
profiles: [],
|
||||
desiredProfileId: '',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
connectionDesired: 'stopped',
|
||||
}));
|
||||
removeSingboxConfig();
|
||||
} else if (mismatchedLegacyMigration) {
|
||||
initialStoredState = stateStore.update((state) => ({
|
||||
...state,
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
connectionDesired: 'stopped',
|
||||
}));
|
||||
removeSingboxConfig();
|
||||
}
|
||||
if (
|
||||
legacySubscriptionCacheBytes !== null
|
||||
&& (
|
||||
Boolean(subscriptionCacheStore.recovery)
|
||||
|| (
|
||||
Boolean(legacyCacheRecord.config)
|
||||
&& (
|
||||
legacySubscriptionCacheRejected
|
||||
|| legacyCacheOwnerMismatch
|
||||
|| normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
const backupPath = subscriptionCacheStore.recovery?.backupPath
|
||||
|| `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
||||
if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes);
|
||||
subscriptionCacheStore.remove();
|
||||
console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`);
|
||||
}
|
||||
|
||||
function readProfileConfig(profileId = '') {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const profile = profileId
|
||||
? state.profiles.find((candidate) => candidate.id === profileId)
|
||||
: desiredProfile(state);
|
||||
return profile?.subscriptionConfig || null;
|
||||
}
|
||||
|
||||
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
||||
const versionInfo = buildVersionInfo(settings.appMode);
|
||||
@@ -215,7 +304,7 @@ const gatewayAutoService = createGatewayAutoService({
|
||||
update: updateStoredState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => readSubscriptionCache()?.config || null,
|
||||
readConfig: (profileId) => readProfileConfig(profileId),
|
||||
},
|
||||
config: {
|
||||
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
|
||||
@@ -238,6 +327,7 @@ const gatewayAutoService = createGatewayAutoService({
|
||||
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||
),
|
||||
restoreRunning: () => startSingbox(),
|
||||
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
|
||||
},
|
||||
discovery: {
|
||||
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
|
||||
@@ -291,7 +381,7 @@ const deviceInventoryRoute = createDeviceInventoryRoute({
|
||||
});
|
||||
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
||||
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => stateStore.read(),
|
||||
readState: () => normalizeStoredState(stateStore.read()),
|
||||
runDiagnostics: async (services, target) => remoteDataplane
|
||||
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
|
||||
: requireLocalConnectivityDiagnostics().run({
|
||||
@@ -306,7 +396,7 @@ const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||
});
|
||||
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
||||
appMode: settings.appMode,
|
||||
readState: () => stateStore.read(),
|
||||
readState: () => normalizeStoredState(stateStore.read()),
|
||||
getHwid,
|
||||
});
|
||||
const sharedProxyRoute = createSharedProxyRoute({
|
||||
@@ -332,11 +422,6 @@ const subscriptionService = createSubscriptionService({
|
||||
read: () => normalizeStoredState(stateStore.read()),
|
||||
update: updateStoredState,
|
||||
},
|
||||
cache: {
|
||||
read: readRawSubscriptionCache,
|
||||
write: (value) => { subscriptionCacheStore.write(value); },
|
||||
remove: () => subscriptionCacheStore.remove(),
|
||||
},
|
||||
config: {
|
||||
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||
@@ -364,16 +449,12 @@ const subscriptionService = createSubscriptionService({
|
||||
clearInterval: (timer) => clearInterval(timer),
|
||||
},
|
||||
onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`),
|
||||
});
|
||||
const subscriptionMutationRoute = createSubscriptionMutationRoute({
|
||||
subscriptionService,
|
||||
readBody,
|
||||
withOperation,
|
||||
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||
now: () => new Date(),
|
||||
});
|
||||
const serverHealthRoute = createServerHealthRoute({
|
||||
serverHealth: createServerHealthService({
|
||||
readServers: () => normalizeStoredState(stateStore.read()).servers,
|
||||
readProfiles: () => normalizeStoredState(stateStore.read()).profiles,
|
||||
readDesiredProfileId: () => normalizeStoredState(stateStore.read()).desiredProfileId,
|
||||
ping: tcpPing,
|
||||
}),
|
||||
readBody,
|
||||
@@ -384,11 +465,7 @@ const connectionService = createConnectionService({
|
||||
read: () => normalizeStoredState(stateStore.read()),
|
||||
update: updateStoredState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => readSubscriptionCache()?.config || null,
|
||||
},
|
||||
config: {
|
||||
exists: () => fs.existsSync(settings.configPath),
|
||||
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||
),
|
||||
@@ -399,6 +476,10 @@ const connectionService = createConnectionService({
|
||||
restore: restoreSingboxConfig,
|
||||
remove: removeSingboxConfig,
|
||||
},
|
||||
route: {
|
||||
isGatewayDirect: () => settings.appMode === 'client'
|
||||
&& gatewayAutoService.read().mode === 'gateway-direct',
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||
start: () => startSingbox(),
|
||||
@@ -412,6 +493,13 @@ const connectionService = createConnectionService({
|
||||
serialize: serializeControl,
|
||||
now: () => new Date(),
|
||||
});
|
||||
const subscriptionMutationRoute = createSubscriptionMutationRoute({
|
||||
subscriptionService,
|
||||
connection: connectionService,
|
||||
readBody,
|
||||
withOperation,
|
||||
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||
});
|
||||
const serverApplyRoute = createServerApplyRoute({
|
||||
connection: connectionService,
|
||||
readBody,
|
||||
@@ -429,7 +517,7 @@ const routeRulesService = createRouteRulesService({
|
||||
update: updateStoredState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => readSubscriptionCache()?.config || null,
|
||||
readConfig: (profileId) => readProfileConfig(profileId),
|
||||
},
|
||||
config: {
|
||||
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||
@@ -460,27 +548,64 @@ const routeRulesRoute = createRouteRulesRoute({
|
||||
});
|
||||
|
||||
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
|
||||
return stateStore.update((stored) => {
|
||||
return normalizeStoredState(stateStore.update((stored) => {
|
||||
const current = normalizeStoredState(stored);
|
||||
const schemaVersion = stored.schemaVersion;
|
||||
const next = normalizeStoredState({ schemaVersion, ...update(current) });
|
||||
revision = Math.max(revision, current.revision) + 1;
|
||||
next.revision = revision;
|
||||
return { ...next, schemaVersion };
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T> {
|
||||
async function withOperation<T>(
|
||||
kind: string,
|
||||
operation: (operationRevision: number) => Promise<T>,
|
||||
{
|
||||
expectedRevision,
|
||||
profileId = null,
|
||||
serverId = null,
|
||||
}: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {},
|
||||
): Promise<T> {
|
||||
if (operationState.status === 'running') throw new HarborError('OPERATION_IN_PROGRESS');
|
||||
const currentRevision = normalizeStoredState(stateStore.read()).revision;
|
||||
if (expectedRevision !== undefined) {
|
||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== currentRevision) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
}
|
||||
operationState = {
|
||||
kind,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
profileId: profileId == null ? null : String(profileId),
|
||||
serverId: serverId == null ? null : String(serverId),
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
let operationRevision: number;
|
||||
try {
|
||||
const result = await operation();
|
||||
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||
operationRevision = updateStoredState((state) => state).revision;
|
||||
} catch (error) {
|
||||
operationState = {
|
||||
kind: null,
|
||||
status: 'idle',
|
||||
startedAt: null,
|
||||
error: null,
|
||||
profileId: null,
|
||||
serverId: null,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = await operation(operationRevision);
|
||||
operationState = {
|
||||
kind: null,
|
||||
status: 'idle',
|
||||
startedAt: null,
|
||||
error: null,
|
||||
profileId: null,
|
||||
serverId: null,
|
||||
};
|
||||
updateStoredState((state) => state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -536,7 +661,8 @@ function buildActiveConfig(
|
||||
selectedServerId: string,
|
||||
routeRules: RouteRule[] = stateStore.read().routeRules,
|
||||
) {
|
||||
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||
const normalizedConfig = normalizeSubscriptionConfig(subscriptionConfig).config;
|
||||
return buildGatewayConfig(normalizedConfig, selectedServerId, {
|
||||
clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
|
||||
routeRules,
|
||||
});
|
||||
@@ -545,33 +671,22 @@ function buildActiveConfig(
|
||||
const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
function writeCurrentConfig(onlyIfSelectionChanged = false) {
|
||||
function writeCurrentConfig() {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedServerId || !cached?.config) return false;
|
||||
const cachedServers = cached.servers as StoredState['servers'];
|
||||
const selectedServerId = selectRefreshedServer(
|
||||
state.selectedServerId,
|
||||
state.servers,
|
||||
cachedServers,
|
||||
);
|
||||
if (onlyIfSelectionChanged && selectedServerId === state.selectedServerId) return false;
|
||||
const activeConfig = selectedServerId
|
||||
? buildActiveConfig(cached.config, selectedServerId)
|
||||
: null;
|
||||
const hasAppliedTarget = Boolean(state.appliedProfileId && state.appliedServerId);
|
||||
const profile = hasAppliedTarget
|
||||
? state.profiles.find((candidate) => candidate.id === state.appliedProfileId) || null
|
||||
: desiredProfile(state);
|
||||
const serverId = hasAppliedTarget ? state.appliedServerId : profile?.desiredServerId;
|
||||
const server = profile?.servers.find((candidate) => candidate.id === serverId);
|
||||
const subscriptionConfig = profile ? readProfileConfig(profile.id) : null;
|
||||
if (!profile || !server || !subscriptionConfig) return null;
|
||||
const activeConfig = buildActiveConfig(subscriptionConfig, server.id);
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
try {
|
||||
if (activeConfig) writeSingboxConfig(activeConfig);
|
||||
else removeSingboxConfig();
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
servers: cachedServers,
|
||||
selectedServerId,
|
||||
appliedServerId: selectedServerId,
|
||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
writeSingboxConfig(activeConfig);
|
||||
} catch (error) {
|
||||
try {
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
@@ -581,7 +696,61 @@ function writeCurrentConfig(onlyIfSelectionChanged = false) {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return Boolean(selectedServerId);
|
||||
return { profile, server };
|
||||
}
|
||||
|
||||
const CONFIG_PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
|
||||
function currentConfigMatchesAppliedTarget(state: StoredState) {
|
||||
if (!state.appliedProfileId || !state.appliedServerId || !state.appliedServerSnapshot) return false;
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = record(JSON.parse(fs.readFileSync(settings.configPath, 'utf8')));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const proxyOutbounds = (Array.isArray(config.outbounds) ? config.outbounds : [])
|
||||
.map(record)
|
||||
.filter((outbound) => CONFIG_PROXY_TYPES.has(String(outbound.type || '')));
|
||||
const exactMatches = proxyOutbounds.filter((outbound) => (
|
||||
String(outbound.tag || '') === state.appliedServerId
|
||||
));
|
||||
const targetMatches = exactMatches.length
|
||||
? exactMatches
|
||||
: proxyOutbounds.filter((outbound) => (
|
||||
serverIdentityKey(outbound) === serverIdentityKey(state.appliedServerSnapshot)
|
||||
));
|
||||
if (targetMatches.length !== 1) return false;
|
||||
const outboundTag = String(targetMatches[0].tag || '');
|
||||
const routeFinal = String(record(config.route).final || '');
|
||||
const expectsGatewayDirect = settings.appMode === 'client'
|
||||
&& gatewayAutoService.read().mode === 'gateway-direct';
|
||||
return expectsGatewayDirect ? routeFinal === 'direct' : routeFinal === outboundTag;
|
||||
}
|
||||
|
||||
async function reconcileStoppedBoot({ removeConfig = false } = {}) {
|
||||
try {
|
||||
await stopSingbox();
|
||||
} catch (error) {
|
||||
console.warn(`[control] sing-box не остановлен при startup reconcile: ${errorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
if (removeConfig) removeSingboxConfig();
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
if (
|
||||
state.connectionDesired !== 'stopped'
|
||||
|| state.appliedProfileId
|
||||
|| state.appliedServerId
|
||||
|| state.appliedServerSnapshot
|
||||
) {
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
connectionDesired: 'stopped',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||
@@ -654,21 +823,45 @@ process.on('SIGINT', shutdown);
|
||||
|
||||
await gatewayAutoService.refresh({ reconfigure: false })
|
||||
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
|
||||
try {
|
||||
writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath));
|
||||
} catch (error) {
|
||||
const candidate = record(error);
|
||||
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||
const bootState = normalizeStoredState(stateStore.read());
|
||||
const bootWantsRunning = bootState.connectionDesired === 'running'
|
||||
|| (bootState.connectionDesired === undefined && fs.existsSync(settings.configPath));
|
||||
if (bootWantsRunning) {
|
||||
let target: ReturnType<typeof writeCurrentConfig> = null;
|
||||
try {
|
||||
target = writeCurrentConfig();
|
||||
} catch (error) {
|
||||
console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`);
|
||||
}
|
||||
const canReuseCurrentConfig = target === null
|
||||
&& fs.existsSync(settings.configPath)
|
||||
&& currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read()));
|
||||
if (target || canReuseCurrentConfig) {
|
||||
await startSingbox()
|
||||
.then(() => {
|
||||
const current = normalizeStoredState(stateStore.read());
|
||||
const appliedProfile = target?.profile
|
||||
|| current.profiles.find((profile) => profile.id === current.appliedProfileId);
|
||||
const appliedServer = target?.server
|
||||
|| current.appliedServerSnapshot;
|
||||
if (appliedProfile && appliedServer) {
|
||||
updateStoredState((state: StoredState) => ({
|
||||
...state,
|
||||
connectionDesired: 'running',
|
||||
appliedProfileId: appliedProfile.id,
|
||||
appliedServerId: appliedServer.id,
|
||||
appliedServerSnapshot: appliedServer,
|
||||
...(target ? { appliedRouteRules: state.routeRules } : {}),
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
|
||||
} else {
|
||||
await reconcileStoppedBoot({ removeConfig: true });
|
||||
}
|
||||
} else {
|
||||
await reconcileStoppedBoot();
|
||||
}
|
||||
await startSingbox()
|
||||
.then(() => {
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
|
||||
|
||||
if (deviceInventory) {
|
||||
await deviceInventory.reconcilePolicies()
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
|
||||
import {
|
||||
normalizeStoredState,
|
||||
type PersistedState,
|
||||
} from '../../shared/contracts/state.js';
|
||||
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||
import {
|
||||
normalizeServers,
|
||||
resolveServerId,
|
||||
serverIdentityKey,
|
||||
type NormalizedServer,
|
||||
} from '../../shared/serverIdentity.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 4;
|
||||
export const STATE_SCHEMA_VERSION = 5;
|
||||
|
||||
export interface AtomicWriteOptions {
|
||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||
@@ -59,6 +68,23 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
function remapLegacyServerId(
|
||||
previousServers: NormalizedServer[],
|
||||
nextServers: NormalizedServer[],
|
||||
serverId: unknown,
|
||||
legacyTag: unknown = '',
|
||||
) {
|
||||
const direct = resolveServerId(nextServers, serverId, legacyTag);
|
||||
if (direct) return direct;
|
||||
const previousId = resolveServerId(previousServers, serverId, legacyTag);
|
||||
const previous = previousServers.find((server) => server.id === previousId);
|
||||
if (!previous) return '';
|
||||
const matches = nextServers.filter((server) => (
|
||||
serverIdentityKey(server) === serverIdentityKey(previous)
|
||||
));
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string) {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
@@ -105,7 +131,10 @@ export function atomicWriteJson(filePath: string, value: unknown, options?: Atom
|
||||
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
||||
}
|
||||
|
||||
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
|
||||
export function migrateStoredState(
|
||||
value: unknown,
|
||||
legacySubscriptionCache: unknown = null,
|
||||
): PersistedState & { schemaVersion: number } {
|
||||
const stored = record(value);
|
||||
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
|
||||
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
||||
@@ -114,10 +143,60 @@ export function migrateStoredState(value: unknown): StoredState & { schemaVersio
|
||||
const routeRules = version < 3
|
||||
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
||||
: stored.routeRules;
|
||||
const legacyCache = record(legacySubscriptionCache);
|
||||
const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim();
|
||||
const cachedSubscriptionUrl = String(legacyCache.url || '').trim();
|
||||
const cacheOwnsStoredSubscription = Boolean(cachedSubscriptionUrl)
|
||||
&& (!storedSubscriptionUrl || cachedSubscriptionUrl === storedSubscriptionUrl);
|
||||
const previousServers = normalizeServers(stored.servers);
|
||||
const cachedServers = cacheOwnsStoredSubscription
|
||||
? normalizeServers(legacyCache.servers)
|
||||
: [];
|
||||
const migratedServers = cachedServers.length ? cachedServers : previousServers;
|
||||
const selectedServerId = remapLegacyServerId(
|
||||
previousServers,
|
||||
migratedServers,
|
||||
stored.selectedServerId,
|
||||
stored.selectedTag,
|
||||
);
|
||||
const appliedServerId = remapLegacyServerId(
|
||||
previousServers,
|
||||
migratedServers,
|
||||
stored.appliedServerId,
|
||||
stored.appliedTag || stored.selectedTag,
|
||||
);
|
||||
const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped');
|
||||
const normalized = normalizeStoredState({
|
||||
...stored,
|
||||
routeRules,
|
||||
...(version < 5 && !Array.isArray(stored.profiles) ? {
|
||||
subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''),
|
||||
subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null,
|
||||
servers: migratedServers,
|
||||
selectedServerId,
|
||||
selectedTag: '',
|
||||
appliedServerId: keepLegacyApplied ? appliedServerId : '',
|
||||
appliedServerSnapshot: keepLegacyApplied ? stored.appliedServerSnapshot : null,
|
||||
appliedTag: '',
|
||||
userInfo: stored.userInfo || (cacheOwnsStoredSubscription ? legacyCache.userInfo : undefined),
|
||||
fetchedAt: stored.fetchedAt || (cacheOwnsStoredSubscription ? legacyCache.fetchedAt : undefined),
|
||||
} : {}),
|
||||
});
|
||||
const canonical = { ...normalized } as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'subscriptionUrl',
|
||||
'selectedServerId',
|
||||
'selectedTag',
|
||||
'appliedTag',
|
||||
'servers',
|
||||
'userInfo',
|
||||
'fetchedAt',
|
||||
'subscriptionConfig',
|
||||
]) delete canonical[key];
|
||||
return {
|
||||
...normalizeStoredState({ ...stored, routeRules }),
|
||||
...canonical,
|
||||
schemaVersion: STATE_SCHEMA_VERSION,
|
||||
};
|
||||
} as PersistedState & { schemaVersion: number };
|
||||
}
|
||||
|
||||
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
|
||||
@@ -204,14 +283,17 @@ export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStor
|
||||
|
||||
export function createStateStore(
|
||||
filePath: string,
|
||||
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
|
||||
options: Partial<Omit<JsonStoreOptions<PersistedState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> & {
|
||||
legacySubscriptionCache?: unknown;
|
||||
} = {},
|
||||
) {
|
||||
return createJsonStore<StoredState & { schemaVersion: number }>({
|
||||
const { legacySubscriptionCache = null, ...storeOptions } = options;
|
||||
return createJsonStore<PersistedState & { schemaVersion: number }>({
|
||||
filePath,
|
||||
defaultValue: migrateStoredState({}),
|
||||
migrate: migrateStoredState,
|
||||
defaultValue: migrateStoredState({}, legacySubscriptionCache),
|
||||
migrate: (value) => migrateStoredState(value, legacySubscriptionCache),
|
||||
initializeMissing: true,
|
||||
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
|
||||
...options,
|
||||
...storeOptions,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user