From f40221969b1fa272558e44fc12bc98ee759cf760 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 9 Aug 2026 12:42:05 +0300 Subject: [PATCH] Preserve VLESS WebSocket variants during subscription refresh --- .gitea/workflows/gateway-build.yml | 11 ++++- src/server/index.ts | 55 ++++++++++++++++------ src/server/subscription.ts | 67 +++++++++++++++++++++++++-- src/shared/versions.ts | 4 +- test/server/deploy-split.test.js | 1 + test/server/state-contract.test.js | 8 +++- test/server/subscription.test.js | 73 ++++++++++++++++++++++++++++++ 7 files changed, 198 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index 3e46ff9..bf6f638 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -97,7 +97,16 @@ jobs: -v "$PWD:/work" \ -w /work \ "${{ env.NODE_BUILD_IMAGE }}" \ - sh -lc 'npm ci --no-audit --no-fund && npm run typecheck && npm run check:boundaries && npm test && npm run build:production' + sh -lc ' + npm ci --no-audit --no-fund + npm_status=$? + if [ "$npm_status" -ne 0 ] || [ ! -x node_modules/.bin/tsc ]; then + echo "npm ci failed to install the validation toolchain." >&2 + tail -n 200 /root/.npm/_logs/*-debug-0.log >&2 || true + exit 1 + fi + npm run typecheck && npm run check:boundaries && npm test && npm run build:production + ' fi if [ "$RESTART_SCOPE" = "none" ]; then echo "Image build and push skipped: no Gateway runtime impact." diff --git a/src/server/index.ts b/src/server/index.ts index e567791..0ce98f2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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(() => { diff --git a/src/server/subscription.ts b/src/server/subscription.ts index 3c0f6d7..72171e0 100644 --- a/src/server/subscription.ts +++ b/src/server/subscription.ts @@ -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 : ''; } diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 03524dd..4a4341d 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.21.0', + macClient: '0.21.1', gatewayClient: '0.22.0', - gatewayBackend: '0.22.0', + gatewayBackend: '0.22.2', }); export interface ParsedVersion { diff --git a/test/server/deploy-split.test.js b/test/server/deploy-split.test.js index cbb488d..75bf60b 100644 --- a/test/server/deploy-split.test.js +++ b/test/server/deploy-split.test.js @@ -23,6 +23,7 @@ test('gateway deploy updates control without recreating dataplane', () => { assert.match(workflow, /git diff --no-renames --name-only "\$BEFORE_SHA"/); assert.match(workflow, /git diff-tree --no-renames/); assert.match(workflow, /git cat-file -e "\$\{BEFORE_SHA\}\^\{commit\}"/); + assert.match(workflow, /npm_status=\$\?[\s\S]*! -x node_modules\/\.bin\/tsc[\s\S]*tail -n 200 \/root\/\.npm\/_logs\/\*-debug-0\.log[\s\S]*exit 1/); assert.match(workflow, /npm test[\s\S]*Image build and push skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*docker login/); assert.match(workflow, /Image build and push skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*\.\/scripts\/build-runtime-base\.sh/); assert.match(workflow, /Deploy skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*bash scripts\/deploy-gateway\.sh/); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 5d7fbde..1119350 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -13,6 +13,7 @@ import { } from '../../dist/shared/contracts/state.js'; import { createServerId } from '../../dist/shared/serverIdentity.js'; import { HARBOR_VERSIONS } from '../../dist/shared/versions.js'; +import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js'; const root = path.resolve(import.meta.dirname, '../..'); @@ -154,10 +155,13 @@ test('data invariant: API mutations return one snapshot, increase revision and r server: 'vpn.example.test', server_port: 443, uuid: '00000000-0000-4000-8000-000000000000', - tls: { enabled: true }, + tls: { enabled: true, server_name: 'edge.example.test' }, + transport: { type: 'ws', path: '/test', headers: { Host: 'edge.example.test' } }, }], }; - const testServerId = createServerId(config.outbounds[0]); + const legacyServerId = createServerId(config.outbounds[0]); + const testServerId = normalizeSubscriptionConfig(config).servers[0].id; + assert.notEqual(testServerId, legacyServerId); fs.mkdirSync(binDir); const singboxPath = path.join(binDir, 'sing-box'); const workingSingbox = `#!/usr/bin/env node diff --git a/test/server/subscription.test.js b/test/server/subscription.test.js index 90b184a..fb7e170 100644 --- a/test/server/subscription.test.js +++ b/test/server/subscription.test.js @@ -5,6 +5,7 @@ import { parseSubscriptionBody, selectRefreshedServer, } from '../../dist/server/subscription.js'; +import { createServerId } from '../../dist/shared/serverIdentity.js'; const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds })); const outbound = (tag, server, server_port = 443) => ({ @@ -86,3 +87,75 @@ test('subscription parser rejects JSON primitives and arrays', () => { ); } }); + +test('mixed Base64 VLESS feed keeps Reality TCP and TLS WebSocket servers', () => { + const reality = 'vless://00000000-0000-4000-8000-000000000001@reality.example.test:443?security=reality&type=tcp&pbk=public-key&sid=short-id&sni=cover.example.test&fp=chrome#Reality'; + const websocket = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsocket%3Fed%3D2048#TLS%20WS'; + const websocketVariant = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsecond#TLS%20WS%202'; + const { config, servers } = parseSubscriptionBody( + Buffer.from(`${reality}\n${websocket}\n${websocketVariant}`).toString('base64'), + ); + const [realityOutbound, websocketOutbound, websocketVariantOutbound] = config.outbounds; + const singleton = parseSubscriptionBody(Buffer.from(websocket).toString('base64')); + const reordered = parseSubscriptionBody( + Buffer.from(`${websocketVariant.replace('TLS%20WS%202', 'Renamed')}\n${websocket.replace('TLS%20WS', 'Also%20renamed')}\n${reality}`).toString('base64'), + ); + const idsByPath = (outbounds) => Object.fromEntries(outbounds + .filter((outbound) => outbound.transport?.type === 'ws') + .map((outbound) => [outbound.transport.path, outbound.tag])); + + assert.equal(servers.length, 3); + assert.notEqual(websocketOutbound.tag, websocketVariantOutbound.tag); + assert.equal(singleton.servers[0].id, websocketOutbound.tag); + assert.notEqual(singleton.servers[0].id, createServerId(singleton.config.outbounds[0])); + assert.deepEqual(idsByPath(reordered.config.outbounds), idsByPath(config.outbounds)); + assert.deepEqual(realityOutbound.tls.reality, { + enabled: true, + public_key: 'public-key', + short_id: 'short-id', + }); + assert.equal(realityOutbound.transport, undefined); + assert.deepEqual(websocketOutbound.tls, { + enabled: true, + server_name: 'edge.example.test', + alpn: ['h2', 'http/1.1'], + utls: { enabled: true, fingerprint: 'firefox' }, + }); + assert.deepEqual(websocketOutbound.transport, { + type: 'ws', + path: '/socket?ed=2048', + headers: { Host: 'edge.example.test' }, + }); + assert.equal(websocketOutbound.packet_encoding, 'xudp'); + assert.equal(websocketVariantOutbound.transport.path, '/second'); + const legacyWebsocketId = createServerId(singleton.config.outbounds[0]); + const legacyWebsocket = [{ ...singleton.servers[0], id: legacyWebsocketId }]; + assert.equal(selectRefreshedServer( + legacyWebsocketId, + legacyWebsocket, + singleton.servers, + ), singleton.servers[0].id); + assert.equal(selectRefreshedServer( + legacyWebsocketId, + legacyWebsocket, + servers.slice(1), + ), ''); + assert.equal(selectRefreshedServer( + legacyWebsocketId, + legacyWebsocket, + [legacyWebsocket[0], singleton.servers[0]], + ), ''); + assert.equal(selectRefreshedServer( + websocketOutbound.tag, + servers, + [servers[0], servers[2]], + ), ''); + assert.throws( + () => parseSubscriptionBody('vless://00000000-0000-4000-8000-000000000003@grpc.example.test:443?security=tls&type=grpc&pbk=public-key&sid=short-id'), + (error) => error.code === 'SUBSCRIPTION_INVALID', + ); + assert.equal( + parseSubscriptionBody(reality.replace('type=tcp', 'type=raw')).config.outbounds[0].transport, + undefined, + ); +});