Preserve VLESS WebSocket variants during subscription refresh
This commit is contained in:
@@ -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."
|
||||||
|
|||||||
+42
-13
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user