import assert from 'node:assert/strict'; import { create } from '@bufbuild/protobuf'; import { connectNodeAdapter } from '@connectrpc/connect-node'; import { spawn } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import http2 from 'node:http2'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { ConnectionEventsSchema, StartedAtSchema, StartedService, StatusSchema, VersionSchema, } from '../../dist/server/generated/daemon/started_service_pb.js'; const root = path.resolve(import.meta.dirname, '../..'); process.env.APP_MODE = 'gateway'; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-')); process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db'); process.env.SING_BOX_TRAFFIC_SOURCE = 'snapshot'; const { buildDualChannelGatewayConfig, buildGatewayConfig, dualChannelConfigMatchesApplied, fingerprintConfiguredOutbound, fingerprintSelectedOutbound, } = await import(`../../dist/server/singbox.js?gateway=${Date.now()}`); const subscriptionConfig = { outbounds: [{ type: 'vless', tag: 'test-vpn', server: 'vpn.example.test', server_port: 443, uuid: '00000000-0000-4000-8000-000000000000', tls: { enabled: true }, }], }; test('gateway preserves mixed user-rule order and dynamic VPN target', () => { const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { routeRules: [ { type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }, { type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' }, { type: 'domain_keyword', value: 'disabled', enabled: false, outbound: 'vpn' }, ], }); assert.deepEqual(config.route.rule_set, []); assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), [ 'tproxy-in', 'mixed-in', 'diagnostics-vpn-in', ]); assert.deepEqual(config.inbounds[2], { type: 'mixed', tag: 'diagnostics-vpn-in', listen: '127.0.0.1', listen_port: 18080, set_system_proxy: false, }); assert.deepEqual(config.route.rules, [ { inbound: ['tproxy-in', 'mixed-in', 'diagnostics-vpn-in'], action: 'sniff', sniffer: ['http', 'tls', 'quic'], timeout: '1s', }, { inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' }, { domain: ['api.example.com'], outbound: 'test-vpn' }, { domain_suffix: ['example.com'], outbound: 'direct' }, { inbound: ['tproxy-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: 'test-vpn' }, ]); assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' }); assert.equal(config.route.final, 'test-vpn'); }); test('gateway dual-channel config fixes probes to role tags and keeps inbound connections', () => { const reserveConfig = structuredClone(subscriptionConfig); reserveConfig.outbounds[0].tag = 'same-provider-tag'; const primaryConfig = structuredClone(subscriptionConfig); primaryConfig.outbounds[0].tag = 'same-provider-tag'; const config = buildDualChannelGatewayConfig({ primary: { subscriptionConfig: primaryConfig, selectedServerId: 'same-provider-tag' }, reserve: { subscriptionConfig: reserveConfig, selectedServerId: 'same-provider-tag' }, }, { routeRules: [{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' }], }); assert.deepEqual(config.outbounds.map(({ tag }) => tag), [ 'channel-primary', 'channel-reserve', 'channel-selector', 'direct', ]); assert.deepEqual(config.outbounds[2], { type: 'selector', tag: 'channel-selector', outbounds: ['channel-primary', 'channel-reserve'], default: 'channel-primary', interrupt_exist_connections: false, }); assert.deepEqual(config.route.rules.slice(1, 5), [ { inbound: ['diagnostics-primary-in'], outbound: 'channel-primary' }, { inbound: ['diagnostics-reserve-in'], outbound: 'channel-reserve' }, { inbound: ['diagnostics-vpn-in'], outbound: 'channel-selector' }, { domain: ['api.example.com'], outbound: 'channel-selector' }, ]); assert.equal(config.route.final, 'channel-selector'); const restoredReserve = buildDualChannelGatewayConfig({ primary: { subscriptionConfig: primaryConfig, selectedServerId: 'same-provider-tag' }, reserve: { subscriptionConfig: reserveConfig, selectedServerId: 'same-provider-tag' }, }, { defaultRole: 'reserve' }); assert.equal(restoredReserve.outbounds[2].default, 'channel-reserve'); }); test('cached dual-channel outbounds must match the applied provider fingerprints', () => { const config = buildDualChannelGatewayConfig({ primary: { subscriptionConfig, selectedServerId: 'test-vpn' }, reserve: { subscriptionConfig, selectedServerId: 'test-vpn' }, }); const expected = fingerprintSelectedOutbound(subscriptionConfig, 'test-vpn'); const applied = { primary: { profileId: 'primary', serverId: 'test-vpn' }, reserve: { profileId: 'reserve', serverId: 'test-vpn' }, primaryConfigFingerprint: expected, reserveConfigFingerprint: expected, }; assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), true); assert.equal(dualChannelConfigMatchesApplied(config, applied, 'reserve'), false); assert.equal(fingerprintConfiguredOutbound(config.outbounds[0], 'test-vpn'), expected); config.outbounds[0].uuid = '11111111-1111-4111-8111-111111111111'; assert.notEqual(fingerprintConfiguredOutbound(config.outbounds[0], 'test-vpn'), expected); assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false); config.outbounds[0].uuid = subscriptionConfig.outbounds[0].uuid; config.outbounds[2].outbounds = ['channel-primary']; assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false); }); function request(socketPath, pathname, method = 'GET', body) { return new Promise((resolve, reject) => { const payload = body === undefined ? null : JSON.stringify(body); const value = http.request({ socketPath, path: pathname, method, ...(payload ? { headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } } : {}), }, (response) => { const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))); }); value.on('error', reject); value.end(payload); }); } async function waitForSocket(socketPath, child, stderr) { for (let attempt = 0; attempt < 100; attempt += 1) { if (child.exitCode !== null) throw new Error(`dataplane exited: ${stderr()}`); try { const status = await request(socketPath, '/status'); if (status.ready) return; } catch {} await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`dataplane did not start: ${stderr()}`); } async function startDataplane(mode, apiPort) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-${mode}-`)); const socketPath = path.join(directory, 'dataplane.sock'); const configPath = path.join(directory, 'sing-box.json'); const binDirectory = path.join(directory, 'bin'); fs.mkdirSync(binDirectory); fs.writeFileSync(configPath, JSON.stringify({ services: [{ type: 'api', listen: '127.0.0.1', listen_port: 19091, dashboard: false, }], inbounds: [], outbounds: [], })); for (const [name, source] of [ ['sing-box', `#!/bin/sh if [ "$1" = check ]; then exit 0; fi trap 'exit 0' TERM INT while :; do sleep 1; done `], ['ip', `#!/bin/sh printf '%s\n' '[{"dst":"192.168.50.7","lladdr":"00:11:22:33:44:55","dev":"en0","state":["REACHABLE"]}]' `], ['iptables', '#!/bin/sh\nexit 0\n'], ['iptables-restore', '#!/bin/sh\nexit 0\n'], ]) { const executable = path.join(binDirectory, name); fs.writeFileSync(executable, source); fs.chmodSync(executable, 0o755); } const child = spawn(process.execPath, ['dist/server/main.js'], { cwd: root, env: { ...process.env, APP_MODE: 'gateway', APP_COMPONENT: 'dataplane', DATA_DIR: directory, DATAPLANE_SOCKET: socketPath, DEVICE_TRAFFIC_ACCOUNTING_ENABLED: 'false', SING_BOX_API_PORT: String(apiPort), SING_BOX_CACHE: path.join(directory, 'cache.db'), SING_BOX_CONFIG: configPath, SING_BOX_API_SECRET: path.join(directory, 'api.secret'), SING_BOX_RUNTIME_CONFIG: path.join(directory, 'runtime-config.json'), SING_BOX_TRAFFIC_SOURCE: mode, PATH: `${binDirectory}:${process.env.PATH || ''}`, }, stdio: ['ignore', 'ignore', 'pipe'], }); let error = ''; child.stderr.on('data', (chunk) => { error += chunk; }); const stop = async () => { if (child.exitCode === null) { const exited = new Promise((resolve) => child.once('exit', resolve)); child.kill('SIGTERM'); await exited; } fs.rmSync(directory, { recursive: true, force: true }); }; try { await waitForSocket(socketPath, child, () => error); return { socketPath, child, stop }; } catch (reason) { await stop(); throw reason; } } function waitForAbort(signal) { return new Promise((resolve) => { if (signal.aborted) resolve(); else signal.addEventListener('abort', resolve, { once: true }); }); } async function waitForSnapshot(socketPath, pathname, predicate, method = 'GET', body) { const deadline = Date.now() + 3_000; while (Date.now() < deadline) { const snapshot = await request(socketPath, pathname, method, body); if (predicate(snapshot)) return snapshot; await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`timed out waiting for ${pathname}`); } function nativeRoutes(router, source) { router.service(StartedService, { getVersion: () => create(VersionSchema, { version: '1.14.0-rc.5', apiVersion: 4 }), getStartedAt: () => create(StartedAtSchema, { startedAt: 1_700_000_000_000n }), async *subscribeConnections(_request, context) { yield create(ConnectionEventsSchema, { reset: true, events: [{ type: 0, id: 'native-1', connection: { id: 'native-1', inbound: 'tproxy-in', inboundType: 'tproxy', network: 'tcp', source: '192.168.50.7:54000', destination: '203.0.113.10:443', domain: 'native.example', protocol: 'tls', createdAt: 1_700_000_000_000n, uplinkTotal: 101n, downlinkTotal: 202n, outbound: 'channel-selector', outboundType: 'selector', chainList: ['channel-primary', 'channel-selector'], }, }], }); await waitForAbort(context.signal); }, async *subscribeStatus(_request, context) { while (!context.signal.aborted) { yield create(StatusSchema, { connectionsIn: source.mismatch ? 2 : 1, uplinkTotal: source.mismatch ? 999n : 101n, downlinkTotal: source.mismatch ? 999n : 202n, }); await Promise.race([ waitForAbort(context.signal), new Promise((resolve) => setTimeout(resolve, 100)), ]); } }, }); } test('Gateway modes keep snapshot compare-only and make native the sole canonical writer', async (t) => { let connectionReads = 0; const nativeSource = { mismatch: false }; const clash = http.createServer((req, res) => { if (req.url === '/connections') connectionReads += 1; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ connections: [{ id: 'legacy-1', upload: 11, download: 22, metadata: { type: 'tproxy/tproxy-in', host: 'legacy.example', sourceIP: '192.168.50.7', }, chains: ['channel-primary'], }] })); }); await new Promise((resolve) => clash.listen(0, '127.0.0.1', resolve)); t.after(() => new Promise((resolve) => clash.close(resolve))); const apiPort = clash.address().port; const native = http2.createServer(connectNodeAdapter({ routes: (router) => nativeRoutes(router, nativeSource), })); await new Promise((resolve) => native.listen(19091, '127.0.0.1', resolve)); t.after(() => new Promise((resolve) => native.close(resolve))); for (const mode of ['snapshot', 'shadow', 'native']) { connectionReads = 0; const dataplane = await startDataplane(mode, apiPort); try { const live = mode === 'snapshot' ? await request(dataplane.socketPath, '/traffic/live') : await waitForSnapshot( dataplane.socketPath, '/traffic/live', (snapshot) => snapshot.source.state === 'live', ); const domain = await waitForSnapshot( dataplane.socketPath, '/domain-traffic', (snapshot) => snapshot.tracked[0]?.uploadBytes === (mode === 'native' ? '101' : '11'), ); assert.equal(domain.source.mode, mode); assert.equal(domain.source.writer, mode === 'native' ? 'native' : 'snapshot'); assert.equal(domain.source.native === null, mode === 'snapshot'); assert.equal(domain.source.shadow === null, mode !== 'shadow'); assert.equal(live.source.state, mode === 'snapshot' ? 'disabled' : 'live'); assert.equal(connectionReads > 0, mode !== 'native'); if (mode === 'shadow') { assert.equal(domain.source.native.active, 1); assert.equal(domain.source.shadow.uploadDifferenceBytes, '90'); assert.equal(domain.source.shadow.downloadDifferenceBytes, '180'); } if (mode === 'native') { assert.equal(connectionReads, 0); assert.equal(live.connections[0].id, 'native-1'); assert.deepEqual(domain.tracked, [{ source: 'gateway', outbound: 'vpn', uploadBytes: '101', downloadBytes: '202', }]); await request(dataplane.socketPath, '/failover/activity', 'PUT', { enabled: true }); await waitForSnapshot( dataplane.socketPath, '/failover/activity/read', (response) => response.activity?.state === 'quiet', 'POST', { thresholdBytesPerSecond: 0 }, ); nativeSource.mismatch = true; await waitForSnapshot( dataplane.socketPath, '/traffic/live', (snapshot) => snapshot.source.state === 'degraded', ); const degradedActivity = await request( dataplane.socketPath, '/failover/activity/read', 'POST', { thresholdBytesPerSecond: 0 }, ); assert.equal(degradedActivity.activity, null); } } finally { await dataplane.stop(); } } });