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
+10 -1
View File
@@ -97,7 +97,16 @@ jobs:
-v "$PWD:/work" \ -v "$PWD:/work" \
-w /work \ -w /work \
"${{ env.NODE_BUILD_IMAGE }}" \ "${{ 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 fi
if [ "$RESTART_SCOPE" = "none" ]; then if [ "$RESTART_SCOPE" = "none" ]; then
echo "Image build and push skipped: no Gateway runtime impact." echo "Image build and push skipped: no Gateway runtime impact."
+38 -9
View File
@@ -545,12 +545,43 @@ function buildActiveConfig(
const stopSingbox = () => singboxRuntime.stop(); const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply(); const startSingbox = () => singboxRuntime.apply();
function writeCurrentConfig() { function writeCurrentConfig(onlyIfSelectionChanged = false) {
const state = stateStore.read(); const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache(); const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) return false; if (!state.selectedServerId || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId)); const cachedServers = cached.servers as StoredState['servers'];
return true; 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) { async function handleApi(req: IncomingMessage, res: ServerResponse) {
@@ -623,15 +654,13 @@ process.on('SIGINT', shutdown);
await gatewayAutoService.refresh({ reconfigure: false }) await gatewayAutoService.refresh({ reconfigure: false })
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) { try {
try { writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath));
writeCurrentConfig(); } catch (error) {
} catch (error) {
const candidate = record(error); const candidate = record(error);
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error; if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`); console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
await subscriptionService.resetSavedSubscription({ stopRuntime: false }); await subscriptionService.resetSavedSubscription({ stopRuntime: false });
}
} }
await startSingbox() await startSingbox()
.then(() => { .then(() => {
+64 -3
View File
@@ -36,6 +36,16 @@ function outboundRecord(value: unknown): SubscriptionOutbound {
return record(value) as 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) { function usableProxyOutbound(value: unknown) {
const outbound = outboundRecord(value); const outbound = outboundRecord(value);
const host = String(outbound.server || '').trim().toLowerCase(); const host = String(outbound.server || '').trim().toLowerCase();
@@ -107,11 +117,52 @@ export function parseVlessUrl(rawUrl: string) {
const serverName = parsed.searchParams.get('sni') || server; const serverName = parsed.searchParams.get('sni') || server;
const fingerprint = parsed.searchParams.get('fp') || 'chrome'; const fingerprint = parsed.searchParams.get('fp') || 'chrome';
const flow = parsed.searchParams.get('flow') || ''; 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) { if (!uuid || !server || !serverPort) {
throw new HarborError('SUBSCRIPTION_INVALID'); 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) { if (!publicKey || !shortId) {
throw new HarborError('SUBSCRIPTION_INVALID'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
@@ -169,8 +220,12 @@ export function normalizeSubscriptionConfig(value: unknown) {
rejectedOutbounds.push(outbound); rejectedOutbounds.push(outbound);
return []; return [];
} }
const id = createServerId(outbound); const endpointId = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants. 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 []; if (seen.has(id)) return [];
seen.add(id); seen.add(id);
servers.push(normalizeServer({ ...outbound, id })); servers.push(normalizeServer({ ...outbound, id }));
@@ -244,11 +299,17 @@ export function selectRefreshedServer(
nextServers: readonly HarborServer[], nextServers: readonly HarborServer[],
) { ) {
if (!currentServerId) return ''; if (!currentServerId) return '';
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
const previous = currentServers.find((server) => server.id === currentServerId); const previous = currentServers.find((server) => server.id === currentServerId);
if (!previous) return ''; if (!previous) return '';
const identity = serverIdentityKey(previous); const identity = serverIdentityKey(previous);
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity); 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 : ''; return matches.length === 1 ? matches[0].id : '';
} }
+2 -2
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.21.0', macClient: '0.21.1',
gatewayClient: '0.22.0', gatewayClient: '0.22.0',
gatewayBackend: '0.22.0', gatewayBackend: '0.22.2',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+1
View File
@@ -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 --no-renames --name-only "\$BEFORE_SHA"/);
assert.match(workflow, /git diff-tree --no-renames/); assert.match(workflow, /git diff-tree --no-renames/);
assert.match(workflow, /git cat-file -e "\$\{BEFORE_SHA\}\^\{commit\}"/); 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, /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, /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/); assert.match(workflow, /Deploy skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*bash scripts\/deploy-gateway\.sh/);
+6 -2
View File
@@ -13,6 +13,7 @@ import {
} from '../../dist/shared/contracts/state.js'; } from '../../dist/shared/contracts/state.js';
import { createServerId } from '../../dist/shared/serverIdentity.js'; import { createServerId } from '../../dist/shared/serverIdentity.js';
import { HARBOR_VERSIONS } from '../../dist/shared/versions.js'; import { HARBOR_VERSIONS } from '../../dist/shared/versions.js';
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
const root = path.resolve(import.meta.dirname, '../..'); 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: 'vpn.example.test',
server_port: 443, server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000', 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); fs.mkdirSync(binDir);
const singboxPath = path.join(binDir, 'sing-box'); const singboxPath = path.join(binDir, 'sing-box');
const workingSingbox = `#!/usr/bin/env node const workingSingbox = `#!/usr/bin/env node
+73
View File
@@ -5,6 +5,7 @@ import {
parseSubscriptionBody, parseSubscriptionBody,
selectRefreshedServer, selectRefreshedServer,
} from '../../dist/server/subscription.js'; } from '../../dist/server/subscription.js';
import { createServerId } from '../../dist/shared/serverIdentity.js';
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds })); const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
const outbound = (tag, server, server_port = 443) => ({ 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,
);
});