Persist operation state in server and reuse returned snapshots
This commit is contained in:
63
docs/product/application-state.md
Normal file
63
docs/product/application-state.md
Normal 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`.
|
||||||
@@ -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: 'Не найдено' });
|
||||||
|
|||||||
179
src/shared/contracts/state.js
Normal file
179
src/shared/contracts/state.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
183
test/server/state-contract.test.js
Normal file
183
test/server/state-contract.test.js
Normal 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, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user