From e81a48a5b1aa5169b2ba763c0db70c6893a142fa Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 18:59:14 +0300 Subject: [PATCH] Persist operation state in server and reuse returned snapshots --- docs/product/application-state.md | 63 +++++++++ src/server/index.js | 218 +++++++++++++++++++---------- src/shared/contracts/state.js | 179 +++++++++++++++++++++++ src/web/App.jsx | 32 ++--- test/server/state-contract.test.js | 183 ++++++++++++++++++++++++ 5 files changed, 585 insertions(+), 90 deletions(-) create mode 100644 docs/product/application-state.md create mode 100644 src/shared/contracts/state.js create mode 100644 test/server/state-contract.test.js diff --git a/docs/product/application-state.md b/docs/product/application-state.md new file mode 100644 index 0000000..0c07e69 --- /dev/null +++ b/docs/product/application-state.md @@ -0,0 +1,63 @@ +# Harbor application state v1 + +`GET /api/state` is the canonical Harbor domain snapshot. Successful POST and DELETE endpoints return the same snapshot as `state` while retaining their v0 response fields for compatibility. + +```json +{ + "apiVersion": 1, + "revision": 42, + "generatedAt": "2026-07-11T15:00:00.000Z", + "mode": "client", + "subscription": { + "status": "ready", + "host": "provider.example/…", + "fetchedAt": "2026-07-11T14:58:00.000Z", + "userInfo": {} + }, + "selection": { + "desiredServerId": "Amsterdam", + "appliedServerId": "Amsterdam" + }, + "connection": { + "desired": "running", + "process": "running", + "startedAt": "2026-07-11T14:59:10.000Z", + "lastError": null + }, + "route": { + "mode": "local-vpn", + "gatewayAddress": null, + "lastVerifiedAt": null, + "reason": "auto" + }, + "operation": { + "kind": null, + "status": "idle", + "startedAt": null, + "error": null + }, + "servers": [] +} +``` + +The backend owns subscription metadata, servers, desired/applied selection, desired/process connection state, route and current operation. React may keep only unsaved form values, pending selection and visual state. Browser transport freshness is not part of this contract. + +## Revision rules + +`revision` is persisted in the existing `state.json` and increases on externally visible transitions, including operation start/completion/failure, import, refresh, forget, apply, start, stop and Gateway Auto changes. `generatedAt` is response metadata and does not change revision by itself. + +A consumer must eventually apply only snapshots whose revision is at least its current revision. The frontend comparison and stale/offline transport envelope are intentionally handled by TASK-002 and TASK-003. + +## Desired and applied state + +`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded. + +Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008. + +## Compatibility and migration + +No path, volume or file is renamed. A legacy `state.json` without `revision`, `appliedTag` or `connectionDesired` is normalized on read: revision starts at `0`, the legacy `selectedTag` is treated as both desired and applied, and connection intent is inferred from the existing config. The next domain write stores the added fields. Existing unknown fields remain untouched. + +During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL. + +Rollback is code-only: deploy the previous build. The added persisted fields are ignored by the previous implementation, so no data rewrite is needed. Revisions created by the newer build remain harmless integers in `state.json`. diff --git a/src/server/index.js b/src/server/index.js index 2dcbaa1..7d45261 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -22,6 +22,11 @@ import { writeSingboxConfig, } from './singbox.js'; import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js'; +import { + createStateSnapshot, + normalizeStoredState, + withStateV0Compatibility, +} from '../shared/contracts/state.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; @@ -43,6 +48,8 @@ let gatewayDiscoveryPromise = null; let gatewayDiscoveryTimer = null; let gatewayAutoState = createGatewayAutoState(); let controlOperation = Promise.resolve(); +let operationState = { kind: null, status: 'idle', startedAt: null, error: null }; +let revision = 0; function readJson(filePath, fallback) { try { @@ -59,6 +66,41 @@ function writeJson(filePath, value) { fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); } +revision = normalizeStoredState(readJson(settings.statePath, {})).revision; + +function updateStoredState(update) { + const current = normalizeStoredState(readJson(settings.statePath, {})); + const next = normalizeStoredState(update(current)); + revision = Math.max(revision, current.revision) + 1; + next.revision = revision; + writeJson(settings.statePath, next); + return next; +} + +async function withOperation(kind, operation) { + operationState = { + kind, + status: 'running', + startedAt: new Date().toISOString(), + error: null, + }; + updateStoredState((state) => state); + try { + const result = await operation(); + operationState = { kind: null, status: 'idle', startedAt: null, error: null }; + updateStoredState((state) => state); + return result; + } catch (error) { + operationState = { + ...operationState, + status: 'failed', + error: error?.message || String(error), + }; + updateStoredState((state) => state); + throw error; + } +} + function serializeControl(operation) { const result = controlOperation.then(operation, operation); controlOperation = result.catch(() => {}); @@ -120,35 +162,26 @@ const stopSingbox = () => singboxRuntime.stop(); const startSingbox = () => singboxRuntime.apply(); async function publicState() { - await singboxRuntime.refresh(); - const state = readJson(settings.statePath, {}); + const runtime = await singboxRuntime.refresh(); + const state = normalizeStoredState(readJson(settings.statePath, {})); const gatewayAutoEnabled = state.gatewayAutoEnabled !== false; - return { - mode: settings.appMode, + const configExists = fs.existsSync(settings.configPath); + const snapshot = createStateSnapshot({ + storedState: state, + runtime, + gatewayAuto: gatewayAutoState, + appMode: settings.appMode, + configExists, + subscriptionHost: subscriptionHost(state.subscriptionUrl), + operation: operationState, + }); + return withStateV0Compatibility(snapshot, { + storedState: { ...state, gatewayAutoEnabled }, + gatewayAuto: gatewayAutoState, port: settings.port, proxyPort: settings.proxyPort, - configExists: fs.existsSync(settings.configPath), - singboxRunning: singboxRuntime.running, - singboxStartedAt: singboxRuntime.startedAt, - subscriptionHost: subscriptionHost(state.subscriptionUrl), - hasSubscription: Boolean(state.subscriptionUrl), - selectedTag: state.selectedTag || '', - userInfo: state.userInfo || {}, - fetchedAt: state.fetchedAt || null, - gatewayAuto: settings.appMode === 'client' ? { - mode: gatewayAutoState.mode, - enabled: gatewayAutoEnabled, - available: Boolean(gatewayAutoState.gatewayId), - address: gatewayAutoState.gateway?.gateway || '', - interface: gatewayAutoState.gateway?.interface || '', - failures: gatewayAutoState.failures, - lastError: gatewayAutoState.lastError, - } : null, - servers: (state.servers || []).map((server) => ({ - ...server, - tag: String(server.tag || '').trim(), - })), - }; + configExists, + }); } function writeCurrentConfig() { @@ -161,9 +194,13 @@ function writeCurrentConfig() { async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) { const previousState = gatewayAutoState; + const stateChanged = !isDeepStrictEqual(previousState, nextState); const modeChanged = previousState.mode !== nextState.mode; gatewayAutoState = nextState; - if (!modeChanged) return; + if (!modeChanged) { + if (stateChanged) updateStoredState((state) => state); + return; + } const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') @@ -179,6 +216,8 @@ async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) { throw error; } + if (stateChanged) updateStoredState((state) => state); + const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : ''; console.log(`[control] client route: ${nextState.mode}${route}`); } @@ -271,10 +310,18 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) { return gatewayDiscoveryPromise; } -async function applySelectedServer(selectedTag) { +async function applySelectedServer(selectedTag, { persist = true } = {}) { const cached = readJson(settings.subscriptionCachePath, null); if (!cached?.config) throw new Error('Сначала загрузите подписку'); + if (persist) { + updateStoredState((state) => ({ + ...state, + selectedTag, + connectionDesired: 'running', + })); + } + const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; @@ -286,11 +333,13 @@ async function applySelectedServer(selectedTag) { else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); throw error; } - writeJson(settings.statePath, { - ...readJson(settings.statePath, {}), - selectedTag, - appliedAt: new Date().toISOString(), - }); + if (persist) { + updateStoredState((state) => ({ + ...state, + appliedTag: selectedTag, + appliedAt: new Date().toISOString(), + })); + } } function refreshSavedSubscription() { @@ -326,7 +375,9 @@ function refreshSavedSubscription() { writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); try { - if (singboxRuntime.running && activeConfigChanged) await applySelectedServer(selectedTag); + if (singboxRuntime.running && activeConfigChanged) { + await applySelectedServer(selectedTag, { persist: false }); + } else if (selectedTag) { if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag)); } else { @@ -340,14 +391,15 @@ function refreshSavedSubscription() { throw error; } - writeJson(settings.statePath, { - ...readJson(settings.statePath, {}), + updateStoredState((state) => ({ + ...state, subscriptionUrl, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, selectedTag, - }); + appliedTag: selectedTag, + })); return { success: true, @@ -364,6 +416,10 @@ function refreshSavedSubscription() { return subscriptionRefreshPromise; } +async function sendState(res, extra = {}) { + return sendJson(res, 200, { success: true, ...extra, state: await publicState() }); +} + async function handleApi(req, res) { if (req.method === 'GET' && req.url === '/api/state') { return sendJson(res, 200, await publicState()); @@ -397,38 +453,47 @@ async function handleApi(req, res) { ...await tcpPing(server.server, server.server_port), checkedAt: new Date().toISOString(), }))); - return sendJson(res, 200, { success: true, results }); + return sendState(res, { results }); } if (req.method === 'POST' && req.url === '/api/subscription/fetch') { const { url = '' } = await readBody(req); const normalizedUrl = String(url).trim(); - const parsed = await fetchSubscription(normalizedUrl); - await serializeControl(async () => { - writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed }); - const previousState = readJson(settings.statePath, {}); - writeJson(settings.statePath, { - subscriptionUrl: normalizedUrl, - gatewayAutoEnabled: previousState.gatewayAutoEnabled !== false, - servers: parsed.servers, - userInfo: parsed.userInfo, - fetchedAt: parsed.fetchedAt, + const parsed = await withOperation('subscription-import', async () => { + const result = await fetchSubscription(normalizedUrl); + await serializeControl(async () => { + await stopSingbox(); + removeSingboxConfig(); + writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...result }); + updateStoredState((state) => ({ + subscriptionUrl: normalizedUrl, + gatewayAutoEnabled: state.gatewayAutoEnabled !== false, + servers: result.servers, + userInfo: result.userInfo, + fetchedAt: result.fetchedAt, + selectedTag: '', + appliedTag: '', + connectionDesired: 'stopped', + })); + gatewayAutoState = createGatewayAutoState(); }); - await stopSingbox(); - removeSingboxConfig(); - gatewayAutoState = createGatewayAutoState(); + return result; }); - return sendJson(res, 200, { success: true, ...parsed }); + return sendState(res, parsed); } if (req.method === 'POST' && req.url === '/api/subscription/validate') { const { url = '' } = await readBody(req); const parsed = await fetchSubscription(String(url).trim()); - return sendJson(res, 200, { success: true, servers: parsed.servers.length }); + return sendState(res, { servers: parsed.servers.length }); } if (req.method === 'POST' && req.url === '/api/subscription/refresh') { - return sendJson(res, 200, await refreshSavedSubscription()); + const { success, ...result } = await withOperation( + 'subscription-refresh', + () => refreshSavedSubscription(), + ); + return sendState(res, result); } if (req.method === 'POST' && req.url === '/api/gateway-auto') { @@ -439,50 +504,59 @@ async function handleApi(req, res) { if (typeof enabled !== 'boolean') { return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' }); } - await serializeControl(async () => { - writeJson(settings.statePath, { - ...readJson(settings.statePath, {}), - gatewayAutoEnabled: enabled, - }); + await withOperation('gateway-auto', () => serializeControl(async () => { await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled)); - }); - return sendJson(res, 200, { success: true, gatewayAuto: (await publicState()).gatewayAuto }); + updateStoredState((state) => ({ + ...state, + gatewayAutoEnabled: enabled, + })); + })); + const state = await publicState(); + return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state }); } if (req.method === 'DELETE' && req.url === '/api/subscription') { - await serializeControl(async () => { + await withOperation('subscription-forget', () => serializeControl(async () => { await stopSingbox(); removeSingboxConfig(); fs.rmSync(settings.subscriptionCachePath, { force: true }); - writeJson(settings.statePath, {}); + updateStoredState(() => ({})); gatewayAutoState = createGatewayAutoState(); - }); - return sendJson(res, 200, { success: true }); + })); + return sendState(res); } if (req.method === 'POST' && req.url === '/api/apply') { const { selectedTag = '' } = await readBody(req); const tag = String(selectedTag).trim(); if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' }); - await serializeControl(() => applySelectedServer(tag)); - return sendJson(res, 200, { success: true, selectedTag: tag }); + await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag))); + return sendState(res, { selectedTag: tag }); } if (req.method === 'POST' && req.url === '/api/singbox/stop') { - await serializeControl(() => stopSingbox()); - return sendJson(res, 200, { success: true, singboxRunning: false }); + await withOperation('stop', () => serializeControl(async () => { + await stopSingbox(); + updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' })); + })); + return sendState(res, { singboxRunning: false }); } if (req.method === 'POST' && req.url === '/api/singbox/restart') { - await serializeControl(async () => { + await withOperation('start', () => serializeControl(async () => { if (!fs.existsSync(settings.configPath)) { const error = new Error('Сначала выберите сервер'); error.statusCode = 400; throw error; } await singboxRuntime.restart(); - }); - return sendJson(res, 200, { success: true, singboxRunning: true }); + updateStoredState((state) => ({ + ...state, + appliedTag: state.selectedTag, + connectionDesired: 'running', + })); + })); + return sendState(res, { singboxRunning: true }); } return sendJson(res, 404, { success: false, error: 'Не найдено' }); diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js new file mode 100644 index 0000000..0f9cebf --- /dev/null +++ b/src/shared/contracts/state.js @@ -0,0 +1,179 @@ +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 : [], + }; +} + +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'; + + 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', + }, + 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' + ); + + 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' || + !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; +} diff --git a/src/web/App.jsx b/src/web/App.jsx index e0de6ce..aa8de67 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -8,15 +8,12 @@ function App() { const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); const [state, setState] = useState(null); const [subscriptionUrl, setSubscriptionUrl] = useState(''); - const [servers, setServers] = useState([]); const [pendingTag, setPendingTag] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); - async function loadState() { - const data = await api.state(); + function syncState(data) { setState(data); - setServers(data.servers || []); setPendingTag((current) => ( (data.servers || []).some((server) => server.tag === current) ? current @@ -24,6 +21,10 @@ function App() { )); } + async function loadState() { + syncState(await api.state()); + } + useEffect(() => { loadState().catch((err) => setError(err.message)); const timer = setInterval(() => loadState().catch(() => {}), 5000); @@ -43,8 +44,10 @@ function App() { setBusy(true); setError(''); try { - await action(); - await loadState(); + const result = await action(); + if (result?.state) syncState(result.state); + else await loadState(); + return result; } catch (err) { setError(err.message); throw err; @@ -56,31 +59,24 @@ function App() { async function fetchSubscription() { return run(async () => { const data = await api.subscription.fetch(subscriptionUrl); - setServers(data.servers || []); setPendingTag(''); + return data; }); } async function refreshSubscription() { const data = await api.subscription.refresh(); - setState((current) => current ? { - ...current, - userInfo: data.userInfo, - fetchedAt: data.fetchedAt, - servers: data.servers, - selectedTag: data.selectedTag, - } : current); - setServers(data.servers || []); + syncState(data.state || data); setPendingTag(data.selectedTag || ''); return data; } async function forgetSubscription() { return run(async () => { - await api.subscription.forget(); + const data = await api.subscription.forget(); setSubscriptionUrl(''); - setServers([]); setPendingTag(''); + return data; }); } @@ -96,7 +92,7 @@ function App() { error={error} subscriptionUrl={subscriptionUrl} setSubscriptionUrl={setSubscriptionUrl} - servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : servers} + servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : state.servers || []} pendingTag={previewReady ? 'Amsterdam' : pendingTag} setPendingTag={setPendingTag} onFetchSubscription={fetchSubscription} diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js new file mode 100644 index 0000000..c26e4bf --- /dev/null +++ b/test/server/state-contract.test.js @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + assertStateSnapshot, + createStateSnapshot, + normalizeStoredState, +} from '../../src/shared/contracts/state.js'; + +const root = path.resolve(import.meta.dirname, '../..'); + +function listen(server) { + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))); +} + +function close(server) { + return new Promise((resolve) => server.close(resolve)); +} + +async function freePort() { + const server = http.createServer(); + const port = await listen(server); + await close(server); + return port; +} + +async function request(port, pathname, method = 'GET', body) { + const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { + method, + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json(); + assert.equal(response.ok, true, JSON.stringify(payload)); + return payload; +} + +async function waitForState(port, child, stderr) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`); + try { + return await request(port, '/api/state'); + } catch { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + throw new Error(`Harbor did not start: ${stderr()}`); +} + +test('state v1 normalizes legacy storage and validates the canonical snapshot', () => { + const stored = normalizeStoredState({ + subscriptionUrl: 'https://provider.example/subscription/full-secret-token', + selectedTag: ' legacy ', + servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }], + }); + const snapshot = createStateSnapshot({ + storedState: stored, + runtime: { running: true, startedAt: '2026-07-11T10:00:00.000Z' }, + gatewayAuto: null, + appMode: 'gateway', + configExists: true, + subscriptionHost: 'provider.example/…', + now: new Date('2026-07-11T12:00:00.000Z'), + }); + + assert.equal(snapshot.apiVersion, 1); + assert.deepEqual(snapshot.selection, { + desiredServerId: 'legacy', + appliedServerId: 'legacy', + }); + assert.equal(snapshot.connection.process, 'running'); + assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false); + assert.throws( + () => assertStateSnapshot({ ...snapshot, revision: -1 }), + /Invalid Harbor state snapshot v1/, + ); +}); + +test('GET and domain mutations return one state shape with monotonic revisions', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-')); + const binDir = path.join(dir, 'bin'); + const config = { + outbounds: [{ + type: 'vless', + tag: 'test-vpn', + server: 'vpn.example.test', + server_port: 443, + uuid: '00000000-0000-4000-8000-000000000000', + tls: { enabled: true }, + }], + }; + fs.mkdirSync(binDir); + fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node +if (process.argv[2] === 'check') process.exit(0); +process.on('SIGTERM', () => process.exit(0)); +setInterval(() => {}, 60_000); +`); + fs.chmodSync(path.join(binDir, 'sing-box'), 0o755); + + const subscriptionServer = http.createServer((req, res) => { + res.writeHead(200, { + 'content-type': 'application/json', + 'subscription-userinfo': 'upload=10; download=20; total=100', + }); + res.end(JSON.stringify(config)); + }); + const subscriptionPort = await listen(subscriptionServer); + const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/full-secret-token`; + fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({ + subscriptionUrl, + selectedTag: 'test-vpn', + servers: [{ tag: 'test-vpn', type: 'vless', server: 'vpn.example.test', server_port: 443 }], + })); + fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({ + url: subscriptionUrl, + config, + })); + + const port = await freePort(); + const child = spawn(process.execPath, ['src/server/index.js'], { + cwd: root, + env: { + ...process.env, + APP_MODE: 'client', + DATA_DIR: dir, + PORT: String(port), + PATH: `${binDir}:${process.env.PATH}`, + HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'), + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + t.after(async () => { + child.kill('SIGTERM'); + if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve)); + await close(subscriptionServer); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const initial = await waitForState(port, child, () => stderr); + assertStateSnapshot(initial); + assert.equal(initial.selection.appliedServerId, 'test-vpn'); + assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false); + const stateKeys = Object.keys(initial).sort(); + let revision = initial.revision; + + async function stateResponse(pathname, method = 'POST', body) { + const result = await request(port, pathname, method, body); + assert.deepEqual(Object.keys(result.state).sort(), stateKeys); + assertStateSnapshot(result.state); + return result; + } + + async function mutation(pathname, method = 'POST', body) { + const result = await stateResponse(pathname, method, body); + assert.ok(result.state.revision > revision, `${pathname} did not increase revision`); + revision = result.state.revision; + return result; + } + + await stateResponse('/api/subscription/validate', 'POST', { url: subscriptionUrl }); + await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl }); + const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' }); + assert.deepEqual(applied.state.selection, { + desiredServerId: 'test-vpn', + appliedServerId: 'test-vpn', + }); + assert.equal(applied.state.connection.process, 'running'); + assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped'); + assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running'); + await mutation('/api/subscription/refresh'); + assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false); + const forgotten = await mutation('/api/subscription', 'DELETE'); + assert.equal(forgotten.state.subscription.status, 'missing'); + assert.equal(forgotten.state.servers.length, 0); + assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []); +});