Preserve VLESS WebSocket variants during subscription refresh
This commit is contained in:
@@ -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."
|
||||
|
||||
+36
-7
@@ -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,16 +654,14 @@ 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();
|
||||
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(() => {
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
|
||||
@@ -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 : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user