397 lines
14 KiB
JavaScript
397 lines
14 KiB
JavaScript
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();
|
|
}
|
|
}
|
|
});
|