Persist operation state in server and reuse returned snapshots
Some checks failed
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Failing after 1s

This commit is contained in:
2026-07-11 18:59:14 +03:00
parent b0b9da51b6
commit e81a48a5b1
5 changed files with 585 additions and 90 deletions

View File

@@ -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`.

View File

@@ -22,6 +22,11 @@ import {
writeSingboxConfig, writeSingboxConfig,
} from './singbox.js'; } from './singbox.js';
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.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 MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -43,6 +48,8 @@ let gatewayDiscoveryPromise = null;
let gatewayDiscoveryTimer = null; let gatewayDiscoveryTimer = null;
let gatewayAutoState = createGatewayAutoState(); let gatewayAutoState = createGatewayAutoState();
let controlOperation = Promise.resolve(); let controlOperation = Promise.resolve();
let operationState = { kind: null, status: 'idle', startedAt: null, error: null };
let revision = 0;
function readJson(filePath, fallback) { function readJson(filePath, fallback) {
try { try {
@@ -59,6 +66,41 @@ function writeJson(filePath, value) {
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); 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) { function serializeControl(operation) {
const result = controlOperation.then(operation, operation); const result = controlOperation.then(operation, operation);
controlOperation = result.catch(() => {}); controlOperation = result.catch(() => {});
@@ -120,35 +162,26 @@ const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply(); const startSingbox = () => singboxRuntime.apply();
async function publicState() { async function publicState() {
await singboxRuntime.refresh(); const runtime = await singboxRuntime.refresh();
const state = readJson(settings.statePath, {}); const state = normalizeStoredState(readJson(settings.statePath, {}));
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false; const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
return { const configExists = fs.existsSync(settings.configPath);
mode: settings.appMode, 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, port: settings.port,
proxyPort: settings.proxyPort, proxyPort: settings.proxyPort,
configExists: fs.existsSync(settings.configPath), configExists,
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(),
})),
};
} }
function writeCurrentConfig() { function writeCurrentConfig() {
@@ -161,9 +194,13 @@ function writeCurrentConfig() {
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) { async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
const previousState = gatewayAutoState; const previousState = gatewayAutoState;
const stateChanged = !isDeepStrictEqual(previousState, nextState);
const modeChanged = previousState.mode !== nextState.mode; const modeChanged = previousState.mode !== nextState.mode;
gatewayAutoState = nextState; gatewayAutoState = nextState;
if (!modeChanged) return; if (!modeChanged) {
if (stateChanged) updateStoredState((state) => state);
return;
}
const previousConfig = fs.existsSync(settings.configPath) const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8') ? fs.readFileSync(settings.configPath, 'utf8')
@@ -179,6 +216,8 @@ async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
throw error; throw error;
} }
if (stateChanged) updateStoredState((state) => state);
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : ''; const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
console.log(`[control] client route: ${nextState.mode}${route}`); console.log(`[control] client route: ${nextState.mode}${route}`);
} }
@@ -271,10 +310,18 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
return gatewayDiscoveryPromise; return gatewayDiscoveryPromise;
} }
async function applySelectedServer(selectedTag) { async function applySelectedServer(selectedTag, { persist = true } = {}) {
const cached = readJson(settings.subscriptionCachePath, null); const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) throw new Error('Сначала загрузите подписку'); if (!cached?.config) throw new Error('Сначала загрузите подписку');
if (persist) {
updateStoredState((state) => ({
...state,
selectedTag,
connectionDesired: 'running',
}));
}
const previousConfig = fs.existsSync(settings.configPath) const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8') ? fs.readFileSync(settings.configPath, 'utf8')
: null; : null;
@@ -286,11 +333,13 @@ async function applySelectedServer(selectedTag) {
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
throw error; throw error;
} }
writeJson(settings.statePath, { if (persist) {
...readJson(settings.statePath, {}), updateStoredState((state) => ({
selectedTag, ...state,
appliedTag: selectedTag,
appliedAt: new Date().toISOString(), appliedAt: new Date().toISOString(),
}); }));
}
} }
function refreshSavedSubscription() { function refreshSavedSubscription() {
@@ -326,7 +375,9 @@ function refreshSavedSubscription() {
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
try { try {
if (singboxRuntime.running && activeConfigChanged) await applySelectedServer(selectedTag); if (singboxRuntime.running && activeConfigChanged) {
await applySelectedServer(selectedTag, { persist: false });
}
else if (selectedTag) { else if (selectedTag) {
if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag)); if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
} else { } else {
@@ -340,14 +391,15 @@ function refreshSavedSubscription() {
throw error; throw error;
} }
writeJson(settings.statePath, { updateStoredState((state) => ({
...readJson(settings.statePath, {}), ...state,
subscriptionUrl, subscriptionUrl,
servers: parsed.servers, servers: parsed.servers,
userInfo: parsed.userInfo, userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt, fetchedAt: parsed.fetchedAt,
selectedTag, selectedTag,
}); appliedTag: selectedTag,
}));
return { return {
success: true, success: true,
@@ -364,6 +416,10 @@ function refreshSavedSubscription() {
return subscriptionRefreshPromise; return subscriptionRefreshPromise;
} }
async function sendState(res, extra = {}) {
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
}
async function handleApi(req, res) { async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') { if (req.method === 'GET' && req.url === '/api/state') {
return sendJson(res, 200, await publicState()); return sendJson(res, 200, await publicState());
@@ -397,38 +453,47 @@ async function handleApi(req, res) {
...await tcpPing(server.server, server.server_port), ...await tcpPing(server.server, server.server_port),
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
}))); })));
return sendJson(res, 200, { success: true, results }); return sendState(res, { results });
} }
if (req.method === 'POST' && req.url === '/api/subscription/fetch') { if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const { url = '' } = await readBody(req); const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim(); const normalizedUrl = String(url).trim();
const parsed = await fetchSubscription(normalizedUrl); const parsed = await withOperation('subscription-import', async () => {
const result = await fetchSubscription(normalizedUrl);
await serializeControl(async () => { 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,
});
await stopSingbox(); await stopSingbox();
removeSingboxConfig(); 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(); gatewayAutoState = createGatewayAutoState();
}); });
return sendJson(res, 200, { success: true, ...parsed }); return result;
});
return sendState(res, parsed);
} }
if (req.method === 'POST' && req.url === '/api/subscription/validate') { if (req.method === 'POST' && req.url === '/api/subscription/validate') {
const { url = '' } = await readBody(req); const { url = '' } = await readBody(req);
const parsed = await fetchSubscription(String(url).trim()); 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') { 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') { if (req.method === 'POST' && req.url === '/api/gateway-auto') {
@@ -439,50 +504,59 @@ async function handleApi(req, res) {
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' }); return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' });
} }
await serializeControl(async () => { await withOperation('gateway-auto', () => serializeControl(async () => {
writeJson(settings.statePath, {
...readJson(settings.statePath, {}),
gatewayAutoEnabled: enabled,
});
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled)); await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
}); updateStoredState((state) => ({
return sendJson(res, 200, { success: true, gatewayAuto: (await publicState()).gatewayAuto }); ...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') { if (req.method === 'DELETE' && req.url === '/api/subscription') {
await serializeControl(async () => { await withOperation('subscription-forget', () => serializeControl(async () => {
await stopSingbox(); await stopSingbox();
removeSingboxConfig(); removeSingboxConfig();
fs.rmSync(settings.subscriptionCachePath, { force: true }); fs.rmSync(settings.subscriptionCachePath, { force: true });
writeJson(settings.statePath, {}); updateStoredState(() => ({}));
gatewayAutoState = createGatewayAutoState(); gatewayAutoState = createGatewayAutoState();
}); }));
return sendJson(res, 200, { success: true }); return sendState(res);
} }
if (req.method === 'POST' && req.url === '/api/apply') { if (req.method === 'POST' && req.url === '/api/apply') {
const { selectedTag = '' } = await readBody(req); const { selectedTag = '' } = await readBody(req);
const tag = String(selectedTag).trim(); const tag = String(selectedTag).trim();
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' }); if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
await serializeControl(() => applySelectedServer(tag)); await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
return sendJson(res, 200, { success: true, selectedTag: tag }); return sendState(res, { selectedTag: tag });
} }
if (req.method === 'POST' && req.url === '/api/singbox/stop') { if (req.method === 'POST' && req.url === '/api/singbox/stop') {
await serializeControl(() => stopSingbox()); await withOperation('stop', () => serializeControl(async () => {
return sendJson(res, 200, { success: true, singboxRunning: false }); await stopSingbox();
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
}));
return sendState(res, { singboxRunning: false });
} }
if (req.method === 'POST' && req.url === '/api/singbox/restart') { if (req.method === 'POST' && req.url === '/api/singbox/restart') {
await serializeControl(async () => { await withOperation('start', () => serializeControl(async () => {
if (!fs.existsSync(settings.configPath)) { if (!fs.existsSync(settings.configPath)) {
const error = new Error('Сначала выберите сервер'); const error = new Error('Сначала выберите сервер');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
await singboxRuntime.restart(); await singboxRuntime.restart();
}); updateStoredState((state) => ({
return sendJson(res, 200, { success: true, singboxRunning: true }); ...state,
appliedTag: state.selectedTag,
connectionDesired: 'running',
}));
}));
return sendState(res, { singboxRunning: true });
} }
return sendJson(res, 404, { success: false, error: 'Не найдено' }); return sendJson(res, 404, { success: false, error: 'Не найдено' });

View File

@@ -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;
}

View File

@@ -8,15 +8,12 @@ function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [state, setState] = useState(null); const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
const [pendingTag, setPendingTag] = useState(''); const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
async function loadState() { function syncState(data) {
const data = await api.state();
setState(data); setState(data);
setServers(data.servers || []);
setPendingTag((current) => ( setPendingTag((current) => (
(data.servers || []).some((server) => server.tag === current) (data.servers || []).some((server) => server.tag === current)
? current ? current
@@ -24,6 +21,10 @@ function App() {
)); ));
} }
async function loadState() {
syncState(await api.state());
}
useEffect(() => { useEffect(() => {
loadState().catch((err) => setError(err.message)); loadState().catch((err) => setError(err.message));
const timer = setInterval(() => loadState().catch(() => {}), 5000); const timer = setInterval(() => loadState().catch(() => {}), 5000);
@@ -43,8 +44,10 @@ function App() {
setBusy(true); setBusy(true);
setError(''); setError('');
try { try {
await action(); const result = await action();
await loadState(); if (result?.state) syncState(result.state);
else await loadState();
return result;
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
throw err; throw err;
@@ -56,31 +59,24 @@ function App() {
async function fetchSubscription() { async function fetchSubscription() {
return run(async () => { return run(async () => {
const data = await api.subscription.fetch(subscriptionUrl); const data = await api.subscription.fetch(subscriptionUrl);
setServers(data.servers || []);
setPendingTag(''); setPendingTag('');
return data;
}); });
} }
async function refreshSubscription() { async function refreshSubscription() {
const data = await api.subscription.refresh(); const data = await api.subscription.refresh();
setState((current) => current ? { syncState(data.state || data);
...current,
userInfo: data.userInfo,
fetchedAt: data.fetchedAt,
servers: data.servers,
selectedTag: data.selectedTag,
} : current);
setServers(data.servers || []);
setPendingTag(data.selectedTag || ''); setPendingTag(data.selectedTag || '');
return data; return data;
} }
async function forgetSubscription() { async function forgetSubscription() {
return run(async () => { return run(async () => {
await api.subscription.forget(); const data = await api.subscription.forget();
setSubscriptionUrl(''); setSubscriptionUrl('');
setServers([]);
setPendingTag(''); setPendingTag('');
return data;
}); });
} }
@@ -96,7 +92,7 @@ function App() {
error={error} error={error}
subscriptionUrl={subscriptionUrl} subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl} 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} pendingTag={previewReady ? 'Amsterdam' : pendingTag}
setPendingTag={setPendingTag} setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription} onFetchSubscription={fetchSubscription}

View File

@@ -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, []);
});