Preserve VLESS WebSocket variants during subscription refresh
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-09 12:42:05 +03:00
parent 90433d7cd8
commit f40221969b
7 changed files with 198 additions and 21 deletions
+42 -13
View File
@@ -545,12 +545,43 @@ function buildActiveConfig(
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
function writeCurrentConfig() {
const state = stateStore.read();
function writeCurrentConfig(onlyIfSelectionChanged = false) {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
return true;
const cachedServers = cached.servers as StoredState['servers'];
const selectedServerId = selectRefreshedServer(
state.selectedServerId,
state.servers,
cachedServers,
);
if (onlyIfSelectionChanged && selectedServerId === state.selectedServerId) return false;
const activeConfig = selectedServerId
? buildActiveConfig(cached.config, selectedServerId)
: null;
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
try {
if (activeConfig) writeSingboxConfig(activeConfig);
else removeSingboxConfig();
updateStoredState((current) => ({
...current,
servers: cachedServers,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
} catch (error) {
try {
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'Current config rollback failed');
}
throw error;
}
return Boolean(selectedServerId);
}
async function handleApi(req: IncomingMessage, res: ServerResponse) {
@@ -623,15 +654,13 @@ process.on('SIGINT', shutdown);
await gatewayAutoService.refresh({ reconfigure: false })
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
try {
writeCurrentConfig();
} catch (error) {
const candidate = record(error);
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
}
try {
writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath));
} catch (error) {
const candidate = record(error);
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
}
await startSingbox()
.then(() => {
+64 -3
View File
@@ -36,6 +36,16 @@ function outboundRecord(value: unknown): SubscriptionOutbound {
return record(value) as SubscriptionOutbound;
}
function connectionVariantKey(value: unknown) {
const outbound = record(value);
const transport = record(outbound.transport);
if (transport.type !== 'ws') return '';
const headers = record(transport.headers);
const tls = record(outbound.tls);
// ponytail: WS routing distinguishes current same-endpoint variants; extend when another real transport needs it.
return ['ws', transport.path || '/', headers.Host || headers.host || '', tls.server_name || ''].join('\u0000');
}
function usableProxyOutbound(value: unknown) {
const outbound = outboundRecord(value);
const host = String(outbound.server || '').trim().toLowerCase();
@@ -107,11 +117,52 @@ export function parseVlessUrl(rawUrl: string) {
const serverName = parsed.searchParams.get('sni') || server;
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
const flow = parsed.searchParams.get('flow') || '';
const security = parsed.searchParams.get('security') || '';
const transportType = parsed.searchParams.get('type') || 'tcp';
const encryption = parsed.searchParams.get('encryption') || '';
if (!uuid || !server || !serverPort) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
if (security === 'tls' && transportType === 'ws') {
if (encryption && encryption !== 'none') {
throw new HarborError('SUBSCRIPTION_INVALID');
}
const websocketHost = parsed.searchParams.get('host') || '';
const alpn = (parsed.searchParams.get('alpn') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
return {
type: 'vless',
tag,
server,
server_port: serverPort,
uuid,
flow,
tls: {
enabled: true,
server_name: serverName,
...(alpn.length ? { alpn } : {}),
utls: {
enabled: true,
fingerprint,
},
},
transport: {
type: 'ws',
path: parsed.searchParams.get('path') || '/',
...(websocketHost ? { headers: { Host: websocketHost } } : {}),
},
packet_encoding: 'xudp',
};
}
if ((security && security !== 'reality') || !['tcp', 'raw'].includes(transportType)) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
if (!publicKey || !shortId) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
@@ -169,8 +220,12 @@ export function normalizeSubscriptionConfig(value: unknown) {
rejectedOutbounds.push(outbound);
return [];
}
const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
const endpointId = createServerId(outbound);
const variant = connectionVariantKey(outbound);
const id = variant
? `srv_${crypto.createHash('sha256').update(`${endpointId}\u0000${variant}`).digest('hex').slice(0, 16)}`
: endpointId;
// ponytail: endpoint plus WS route deduplicates indistinguishable entries; include provider IDs if a real feed needs more.
if (seen.has(id)) return [];
seen.add(id);
servers.push(normalizeServer({ ...outbound, id }));
@@ -244,11 +299,17 @@ export function selectRefreshedServer(
nextServers: readonly HarborServer[],
) {
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);
if (matches.some((server) => server.id === currentServerId)) {
return currentServerId === createServerId(previous) && matches.length > 1 ? '' : currentServerId;
}
if (
currentServerId !== createServerId(previous) ||
currentServers.filter((server) => serverIdentityKey(server) === identity).length !== 1
) return '';
return matches.length === 1 ? matches[0].id : '';
}