Harden server state and config persistence
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user