210 lines
7.5 KiB
JavaScript
210 lines
7.5 KiB
JavaScript
import { normalizeRouteRules } from '../routingRules.js';
|
|
|
|
const MODES = new Set(['client', 'gateway']);
|
|
const CONNECTION_STATES = new Set(['running', 'stopped']);
|
|
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
|
|
|
|
const text = (value) => String(value || '').trim();
|
|
const nullableText = (value) => value == null ? null : String(value);
|
|
const dateOrNull = (value) => (
|
|
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
|
|
);
|
|
|
|
export function normalizeStoredState(value) {
|
|
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
const selectedTag = text(state.selectedTag);
|
|
return {
|
|
...state,
|
|
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
|
|
selectedTag,
|
|
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
|
servers: Array.isArray(state.servers) ? state.servers : [],
|
|
routeRules: normalizeRouteRules(state.routeRules),
|
|
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
|
|
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
|
? state.routeRulesRevision
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
export function createStateSnapshot({
|
|
storedState,
|
|
runtime,
|
|
gatewayAuto,
|
|
appMode,
|
|
configExists,
|
|
subscriptionHost,
|
|
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
|
now = new Date(),
|
|
}) {
|
|
const stored = normalizeStoredState(storedState);
|
|
const mode = MODES.has(appMode) ? appMode : 'gateway';
|
|
const hasSubscription = Boolean(stored.subscriptionUrl);
|
|
const desired = CONNECTION_STATES.has(stored.connectionDesired)
|
|
? stored.connectionDesired
|
|
: configExists ? 'running' : 'stopped';
|
|
const servers = stored.servers.map((server) => {
|
|
const tag = text(server.tag);
|
|
return {
|
|
...server,
|
|
id: tag,
|
|
label: tag,
|
|
host: text(server.server),
|
|
port: Number(server.server_port) || 0,
|
|
protocol: text(server.type),
|
|
// v0 compatibility: the current UI and integrations still read these aliases.
|
|
tag,
|
|
server: text(server.server),
|
|
server_port: Number(server.server_port) || 0,
|
|
type: text(server.type),
|
|
};
|
|
});
|
|
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
|
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
|
|
|
return assertStateSnapshot({
|
|
apiVersion: 1,
|
|
revision: stored.revision,
|
|
generatedAt: now.toISOString(),
|
|
mode,
|
|
subscription: {
|
|
status: hasSubscription ? 'ready' : 'missing',
|
|
host: hasSubscription ? subscriptionHost : '',
|
|
fetchedAt: dateOrNull(stored.fetchedAt),
|
|
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
|
|
},
|
|
selection: {
|
|
desiredServerId: stored.selectedTag,
|
|
appliedServerId: stored.appliedTag,
|
|
},
|
|
connection: {
|
|
desired,
|
|
process: runtime?.running ? 'running' : 'stopped',
|
|
startedAt: dateOrNull(runtime?.startedAt),
|
|
lastError: null,
|
|
},
|
|
route: {
|
|
mode: routeMode,
|
|
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
|
lastVerifiedAt: null,
|
|
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
|
localRules: stored.routeRules,
|
|
activeLocalRules,
|
|
localRulesRevision: stored.routeRulesRevision,
|
|
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
|
},
|
|
operation: {
|
|
kind: nullableText(operation.kind),
|
|
status: operation.status,
|
|
startedAt: nullableText(operation.startedAt),
|
|
error: nullableText(operation.error),
|
|
},
|
|
servers,
|
|
});
|
|
}
|
|
|
|
export function withStateV0Compatibility(snapshot, {
|
|
storedState,
|
|
gatewayAuto,
|
|
port,
|
|
proxyPort,
|
|
configExists,
|
|
}) {
|
|
const stored = normalizeStoredState(storedState);
|
|
return {
|
|
...snapshot,
|
|
port,
|
|
proxyPort,
|
|
configExists,
|
|
singboxRunning: snapshot.connection.process === 'running',
|
|
singboxStartedAt: snapshot.connection.startedAt,
|
|
subscriptionHost: snapshot.subscription.host,
|
|
hasSubscription: snapshot.subscription.status === 'ready',
|
|
selectedTag: snapshot.selection.desiredServerId,
|
|
userInfo: snapshot.subscription.userInfo,
|
|
fetchedAt: snapshot.subscription.fetchedAt,
|
|
gatewayAuto: snapshot.mode === 'client' ? {
|
|
mode: gatewayAuto?.mode || 'local-vpn',
|
|
enabled: stored.gatewayAutoEnabled !== false,
|
|
available: Boolean(gatewayAuto?.gatewayId),
|
|
address: gatewayAuto?.gateway?.gateway || '',
|
|
interface: gatewayAuto?.gateway?.interface || '',
|
|
failures: Number(gatewayAuto?.failures) || 0,
|
|
lastError: gatewayAuto?.lastError || '',
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
export function assertStateSnapshot(snapshot) {
|
|
const validDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
const nullableDate = (value) => value === null || validDate(value);
|
|
const nullableString = (value) => value === null || typeof value === 'string';
|
|
const validServer = (server) => (
|
|
server &&
|
|
typeof server.id === 'string' &&
|
|
typeof server.label === 'string' &&
|
|
typeof server.host === 'string' &&
|
|
Number.isInteger(server.port) &&
|
|
server.port >= 0 &&
|
|
typeof server.protocol === 'string'
|
|
);
|
|
const validRouteRule = (rule) => (
|
|
rule &&
|
|
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
|
typeof rule.value === 'string' &&
|
|
Boolean(rule.value) &&
|
|
typeof rule.enabled === 'boolean'
|
|
);
|
|
|
|
if (
|
|
!snapshot ||
|
|
snapshot.apiVersion !== 1 ||
|
|
!Number.isSafeInteger(snapshot.revision) ||
|
|
snapshot.revision < 0 ||
|
|
!validDate(snapshot.generatedAt) ||
|
|
!MODES.has(snapshot.mode) ||
|
|
!snapshot.subscription ||
|
|
!['missing', 'ready'].includes(snapshot.subscription.status) ||
|
|
typeof snapshot.subscription.host !== 'string' ||
|
|
Object.hasOwn(snapshot.subscription, 'url') ||
|
|
!nullableDate(snapshot.subscription.fetchedAt) ||
|
|
!snapshot.subscription.userInfo ||
|
|
typeof snapshot.subscription.userInfo !== 'object' ||
|
|
!snapshot.selection ||
|
|
typeof snapshot.selection.desiredServerId !== 'string' ||
|
|
typeof snapshot.selection.appliedServerId !== 'string' ||
|
|
!snapshot.connection ||
|
|
!CONNECTION_STATES.has(snapshot.connection.desired) ||
|
|
!CONNECTION_STATES.has(snapshot.connection.process) ||
|
|
!nullableDate(snapshot.connection.startedAt) ||
|
|
!nullableString(snapshot.connection.lastError) ||
|
|
!snapshot.route ||
|
|
typeof snapshot.route.mode !== 'string' ||
|
|
!nullableString(snapshot.route.gatewayAddress) ||
|
|
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
|
typeof snapshot.route.reason !== 'string' ||
|
|
!Array.isArray(snapshot.route.localRules) ||
|
|
!snapshot.route.localRules.every(validRouteRule) ||
|
|
!Array.isArray(snapshot.route.activeLocalRules) ||
|
|
!snapshot.route.activeLocalRules.every(validRouteRule) ||
|
|
!Number.isSafeInteger(snapshot.route.localRulesRevision) ||
|
|
snapshot.route.localRulesRevision < 0 ||
|
|
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
|
|
!snapshot.operation ||
|
|
!nullableString(snapshot.operation.kind) ||
|
|
!OPERATION_STATES.has(snapshot.operation.status) ||
|
|
!nullableDate(snapshot.operation.startedAt) ||
|
|
!nullableString(snapshot.operation.error) ||
|
|
!Array.isArray(snapshot.servers) ||
|
|
!snapshot.servers.every(validServer)
|
|
) {
|
|
throw new TypeError('Invalid Harbor state snapshot v1');
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
function isSameRules(left, right) {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|