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

@@ -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: 'Не найдено' });

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 [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}