Harden server state and config persistence
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-11 21:07:11 +03:00
parent e6b21ed8a9
commit c9223aa3a9
6 changed files with 338 additions and 50 deletions

View File

@@ -20,6 +20,7 @@ import { buildSharedProxyInfo } from './sharedProxy.js';
import {
buildGatewayConfig,
removeSingboxConfig,
restoreSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
@@ -29,6 +30,7 @@ import {
withStateV0Compatibility,
} from '../shared/contracts/state.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -36,6 +38,30 @@ const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
fs.mkdirSync(settings.dataDir, { recursive: true });
const stateStore = createStateStore(settings.statePath);
const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath,
defaultValue: null,
});
let cacheRecoveryLogged = false;
function readSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached;
}
const initialStoredState = stateStore.read();
if (stateStore.migration) {
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
}
if (stateStore.recovery) {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
}
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
const singboxRuntime = remoteDataplane
? createDataplaneClient(settings.dataplaneSocket)
@@ -50,33 +76,22 @@ 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 {
return fs.existsSync(filePath)
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
: fallback;
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
}
revision = normalizeStoredState(readJson(settings.statePath, {})).revision;
let operationState = stateStore.recovery ? {
kind: 'storage-recovery',
status: 'failed',
startedAt: stateStore.recovery.recoveredAt,
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
} : { kind: null, status: 'idle', startedAt: null, error: null };
let revision = normalizeStoredState(initialStoredState).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;
return stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const next = normalizeStoredState(update(current));
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return next;
});
}
async function withOperation(kind, operation) {
@@ -186,7 +201,7 @@ const startSingbox = () => singboxRuntime.apply();
async function publicState() {
const runtime = await singboxRuntime.refresh();
const state = normalizeStoredState(readJson(settings.statePath, {}));
const state = normalizeStoredState(stateStore.read());
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
const configExists = fs.existsSync(settings.configPath);
const snapshot = createStateSnapshot({
@@ -208,8 +223,8 @@ async function publicState() {
}
function writeCurrentConfig() {
const state = readJson(settings.statePath, {});
const cached = readJson(settings.subscriptionCachePath, null);
const state = stateStore.read();
const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag));
return true;
@@ -235,7 +250,7 @@ async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
} catch (error) {
gatewayAutoState = previousState;
if (previousConfig === null) removeSingboxConfig();
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
else restoreSingboxConfig(previousConfig);
throw error;
}
@@ -250,7 +265,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
gatewayDiscoveryPromise = serializeControl(async () => {
const state = readJson(settings.statePath, {});
const state = stateStore.read();
const network = state.subscriptionUrl
? readHostNetworkState(settings.hostNetworkStatePath)
: null;
@@ -283,7 +298,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
port: settings.gatewayPresencePort,
subscriptionUrl: state.subscriptionUrl,
});
const latestState = readJson(settings.statePath, {});
const latestState = stateStore.read();
const latestNetwork = latestState.subscriptionUrl
? readHostNetworkState(settings.hostNetworkStatePath)
: null;
@@ -303,7 +318,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
);
} catch (error) {
const reason = error?.message || 'Gateway presence check failed';
const latestState = readJson(settings.statePath, {});
const latestState = stateStore.read();
const latestNetwork = latestState.subscriptionUrl
? readHostNetworkState(settings.hostNetworkStatePath)
: null;
@@ -334,7 +349,7 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
}
async function applySelectedServer(selectedTag, { persist = true } = {}) {
const cached = readJson(settings.subscriptionCachePath, null);
const cached = readSubscriptionCache();
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
const nextConfig = buildActiveConfig(cached.config, selectedTag);
@@ -354,7 +369,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
await startSingbox();
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
else restoreSingboxConfig(previousConfig);
throw error;
}
if (persist) {
@@ -370,7 +385,7 @@ function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => {
const initialState = readJson(settings.statePath, {});
const initialState = stateStore.read();
if (!initialState.subscriptionUrl) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
@@ -378,13 +393,13 @@ function refreshSavedSubscription() {
const subscriptionUrl = initialState.subscriptionUrl;
const parsed = await fetchSubscription(subscriptionUrl);
return serializeControl(async () => {
const currentState = readJson(settings.statePath, {});
const currentState = stateStore.read();
if (currentState.subscriptionUrl !== subscriptionUrl) {
throw new HarborError('STATE_CONFLICT');
}
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
const previousCache = readJson(settings.subscriptionCachePath, null);
const previousCache = readSubscriptionCache();
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
@@ -394,7 +409,7 @@ function refreshSavedSubscription() {
buildActiveConfig(parsed.config, selectedTag),
)
);
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
try {
if (singboxRuntime.running && activeConfigChanged) {
@@ -406,10 +421,10 @@ function refreshSavedSubscription() {
removeSingboxConfig();
}
} catch (error) {
if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
else fs.rmSync(settings.subscriptionCachePath, { force: true });
if (previousCache) subscriptionCacheStore.write(previousCache);
else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig();
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
else restoreSingboxConfig(previousConfig);
throw error;
}
@@ -459,7 +474,7 @@ async function handleApi(req, res) {
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
const state = readJson(settings.statePath, {});
const state = stateStore.read();
return sendJson(res, 200, buildGatewayPresence({
appMode: settings.appMode,
subscriptionUrl: state.subscriptionUrl,
@@ -469,7 +484,7 @@ async function handleApi(req, res) {
}
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
const state = readJson(settings.statePath, {});
const state = stateStore.read();
const results = await Promise.all((state.servers || []).map(async (server) => ({
tag: String(server.tag || '').trim(),
...await tcpPing(server.server, server.server_port),
@@ -486,7 +501,7 @@ async function handleApi(req, res) {
await serializeControl(async () => {
await stopSingbox();
removeSingboxConfig();
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...result });
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
updateStoredState((state) => ({
subscriptionUrl: normalizedUrl,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
@@ -541,7 +556,7 @@ async function handleApi(req, res) {
await withOperation('subscription-forget', () => serializeControl(async () => {
await stopSingbox();
removeSingboxConfig();
fs.rmSync(settings.subscriptionCachePath, { force: true });
subscriptionCacheStore.remove();
updateStoredState(() => ({}));
gatewayAutoState = createGatewayAutoState();
}));
@@ -638,7 +653,7 @@ server.listen(settings.port, '0.0.0.0', () => {
});
subscriptionRefreshTimer = setInterval(() => {
if (!readJson(settings.statePath, {}).subscriptionUrl) return;
if (!stateStore.read().subscriptionUrl) return;
refreshSavedSubscription()
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);