Introduce stable server IDs for subscription state
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-12 11:59:22 +03:00
parent 005c7a101b
commit 267afc5c7e
16 changed files with 323 additions and 145 deletions
+59 -30
View File
@@ -23,7 +23,12 @@ import {
restoreSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
import {
fetchSubscription,
getHwid,
normalizeSubscriptionConfig,
selectRefreshedServer,
} from './subscription.js';
import {
createStateSnapshot,
normalizeStoredState,
@@ -53,7 +58,9 @@ function readSubscriptionCache() {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached;
return cached?.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
: cached;
}
const initialStoredState = stateStore.read();
@@ -195,10 +202,10 @@ function subscriptionHost(url) {
function buildActiveConfig(
subscriptionConfig,
selectedTag,
selectedServerId,
routeRules = stateStore.read().routeRules,
) {
return buildGatewayConfig(subscriptionConfig, selectedTag, {
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
routeRules,
});
@@ -233,8 +240,8 @@ async function publicState() {
function writeCurrentConfig() {
const state = stateStore.read();
const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag));
if (!state.selectedServerId || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
return true;
}
@@ -356,15 +363,15 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
return gatewayDiscoveryPromise;
}
async function applySelectedServer(selectedTag, { persist = true } = {}) {
async function applySelectedServer(selectedServerId, { persist = true } = {}) {
const cached = readSubscriptionCache();
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
const nextConfig = buildActiveConfig(cached.config, selectedTag);
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
if (persist) {
updateStoredState((state) => ({
...state,
selectedTag,
selectedServerId,
connectionDesired: 'running',
}));
}
@@ -383,7 +390,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
updateStoredState((state) => ({
...state,
...(persist ? {
appliedTag: selectedTag,
appliedServerId: selectedServerId,
appliedAt: new Date().toISOString(),
} : {}),
appliedRouteRules: state.routeRules,
@@ -393,7 +400,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
async function applyRouteRules(routeRules) {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) {
if (!state.selectedServerId || !cached?.config) {
updateStoredState((current) => ({
...current,
routeRules,
@@ -407,7 +414,7 @@ async function applyRouteRules(routeRules) {
: null;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
if (wasRunning) await startSingbox();
updateStoredState((current) => ({
...current,
@@ -438,11 +445,15 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
throw new HarborError('STATE_CONFLICT');
}
const selectedTag = resetSelection
const selectedServerId = resetSelection
? ''
: selectRefreshedServer(previousState.selectedTag, parsed.servers);
const candidateConfig = selectedTag
? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules)
: selectRefreshedServer(
previousState.selectedServerId,
previousState.servers,
parsed.servers,
);
const candidateConfig = selectedServerId
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
: null;
const previousCache = readSubscriptionCache();
const previousConfig = fs.existsSync(settings.configPath)
@@ -452,10 +463,16 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
if (resetSelection && wasRunning) await stopSingbox();
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
if (candidateConfig) writeSingboxConfig(candidateConfig);
else removeSingboxConfig();
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
subscriptionCacheStore.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
updateStoredState((state) => ({
@@ -468,13 +485,14 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedTag,
appliedTag: selectedTag,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
if (resetSelection) gatewayAutoState = createGatewayAutoState();
} catch (error) {
gatewayAutoState = previousGatewayAutoState;
if (previousCache) subscriptionCacheStore.write(previousCache);
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
@@ -495,7 +513,8 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedTag,
selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
};
});
}
@@ -559,8 +578,9 @@ async function handleApi(req, res) {
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
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),
id: server.id,
tag: server.label,
...await tcpPing(server.host, server.port),
checkedAt: new Date().toISOString(),
})));
return sendState(res, { results });
@@ -644,11 +664,20 @@ async function handleApi(req, res) {
}
if (req.method === 'POST' && req.url === '/api/apply') {
const { selectedTag = '' } = await readBody(req);
const tag = String(selectedTag).trim();
if (!tag) throw new HarborError('REQUEST_INVALID');
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
return sendState(res, { selectedTag: tag });
const { serverId = '', selectedTag = '' } = await readBody(req);
const state = normalizeStoredState(stateStore.read());
const id = String(serverId).trim() || (() => {
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
return matches.length === 1 ? matches[0].id : '';
})();
if (!id || !state.servers.some((server) => server.id === id)) {
throw new HarborError('SERVER_NOT_FOUND');
}
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
return sendState(res, {
serverId: id,
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
});
}
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
@@ -667,7 +696,7 @@ async function handleApi(req, res) {
await singboxRuntime.restart();
updateStoredState((state) => ({
...state,
appliedTag: state.selectedTag,
appliedServerId: state.selectedServerId,
connectionDesired: 'running',
appliedRouteRules: state.routeRules,
}));
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { normalizeStoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 3;
export const STATE_SCHEMA_VERSION = 4;
const clone = (value) => structuredClone(value);
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
+34 -23
View File
@@ -2,6 +2,11 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import {
createServerId,
normalizeServer,
serverIdentityKey,
} from '../shared/serverIdentity.js';
import { atomicWriteFile } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
@@ -105,8 +110,27 @@ function maybeDecodeBase64(content) {
return content;
}
export function normalizeSubscriptionConfig(value) {
const parsedConfig = value && typeof value === 'object' ? value : {};
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = [];
const seen = new Set();
const normalizedOutbounds = outbounds.flatMap((outbound) => {
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
if (seen.has(id)) return [];
seen.add(id);
servers.push(normalizeServer({ ...outbound, id }));
return [{ ...outbound, tag: id }];
});
if (!servers.length) throw new HarborError('SUBSCRIPTION_INVALID');
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
}
export function parseSubscriptionBody(body) {
let parsedConfig = null;
let parsedConfig;
try {
parsedConfig = JSON.parse(body);
@@ -126,21 +150,7 @@ export function parseSubscriptionBody(body) {
};
}
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = outbounds
.filter((outbound) => PROXY_TYPES.has(outbound.type))
.map((outbound) => ({
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
type: outbound.type,
server: outbound.server || 'unknown',
server_port: outbound.server_port || 443,
}));
if (!servers.length) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
return { config: parsedConfig, servers };
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
}
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
@@ -173,13 +183,14 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
return response;
}
export function selectRefreshedServer(currentTag, servers) {
const selectedTag = String(currentTag || '').trim();
if (!selectedTag) return '';
return servers.find((server) => String(server.tag || '').trim() === selectedTag)?.tag
|| servers[0]?.tag
|| '';
export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
if (!currentServerId) return '';
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
const previous = currentServers.find((server) => server.id === currentServerId);
if (!previous) return '';
const identity = serverIdentityKey(previous);
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
return matches.length === 1 ? matches[0].id : '';
}
export async function fetchSubscription(url, options) {