Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLiveTrafficService } from '../../dist/server/services/liveTrafficService.js';
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [{
|
||||
type: 'vless',
|
||||
tag: 'test-vpn',
|
||||
server: 'vpn.example.test',
|
||||
server_port: 443,
|
||||
uuid: '00000000-0000-4000-8000-000000000000',
|
||||
tls: { enabled: true },
|
||||
}],
|
||||
};
|
||||
|
||||
function buildClientConfig(trafficSource) {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-native-${trafficSource}-`));
|
||||
const singboxUrl = pathToFileURL(path.resolve('dist/server/singbox.js')).href;
|
||||
const script = `
|
||||
const { buildGatewayConfig } = await import(${JSON.stringify(singboxUrl)});
|
||||
const config = buildGatewayConfig(${JSON.stringify(subscriptionConfig)}, 'test-vpn');
|
||||
process.stdout.write(JSON.stringify(config));
|
||||
`;
|
||||
try {
|
||||
return JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: path.resolve('.'),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APP_MODE: 'client',
|
||||
DATA_DIR: dataDir,
|
||||
SING_BOX_CACHE: path.join(dataDir, 'cache.db'),
|
||||
SING_BOX_TRAFFIC_SOURCE: trafficSource,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function abortableStream(value, signal) {
|
||||
return (async function* stream() {
|
||||
yield value;
|
||||
await new Promise((_, reject) => {
|
||||
const abort = () => reject(signal.reason || new Error('aborted'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}());
|
||||
}
|
||||
|
||||
async function waitFor(check, timeout = 1_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (!check()) {
|
||||
if (Date.now() >= deadline) throw new Error('Timed out waiting for native traffic collector');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
test('client native mode adds the private API service and removes the retired DNS option', () => {
|
||||
const config = buildClientConfig('native');
|
||||
assert.deepEqual(config.services, [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
}]);
|
||||
assert.deepEqual(config.dns, {});
|
||||
assert.equal(config.experimental.clash_api, undefined);
|
||||
});
|
||||
|
||||
test('client disabled mode omits the API service and keeps the 1.13-compatible DNS option', () => {
|
||||
const config = buildClientConfig('disabled');
|
||||
assert.equal(config.services, undefined);
|
||||
assert.deepEqual(config.dns, { independent_cache: true });
|
||||
assert.equal(config.experimental.clash_api, undefined);
|
||||
});
|
||||
|
||||
test('native collector authenticates all RC5 lifecycle calls and uses one-second Go duration intervals', async () => {
|
||||
const requests = [];
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
inbound: 'mixed-in',
|
||||
inboundType: 'mixed',
|
||||
network: 'tcp',
|
||||
source: '127.0.0.1:54000',
|
||||
destination: '203.0.113.10:443',
|
||||
domain: 'example.test',
|
||||
protocol: 'tls',
|
||||
createdAt: 1_700_000_000_000n,
|
||||
closedAt: 0n,
|
||||
uplinkTotal: 12n,
|
||||
downlinkTotal: 34n,
|
||||
outbound: 'test-vpn',
|
||||
outboundType: 'vless',
|
||||
rule: 'final',
|
||||
chainList: ['test-vpn'],
|
||||
};
|
||||
const clientFactory = (port) => {
|
||||
requests.push(['factory', port]);
|
||||
return {
|
||||
async getVersion(_input, { signal, headers }) {
|
||||
requests.push(['version', signal instanceof AbortSignal, headers?.authorization]);
|
||||
return { version: '1.14.0-rc.5', apiVersion: 4 };
|
||||
},
|
||||
async getStartedAt(_input, { signal, headers }) {
|
||||
requests.push(['started-at', signal instanceof AbortSignal, headers?.authorization]);
|
||||
return { startedAt: 1_700_000_000_000n };
|
||||
},
|
||||
subscribeConnections({ interval }, { signal, headers }) {
|
||||
requests.push(['connections', interval, headers?.authorization]);
|
||||
return abortableStream({
|
||||
reset: true,
|
||||
events: [{ type: 0, id: connection.id, connection }],
|
||||
}, signal);
|
||||
},
|
||||
subscribeStatus({ interval }, { signal, headers }) {
|
||||
requests.push(['status', interval, headers?.authorization]);
|
||||
return abortableStream({
|
||||
connectionsIn: 1,
|
||||
uplinkTotal: 12n,
|
||||
downlinkTotal: 34n,
|
||||
}, signal);
|
||||
},
|
||||
};
|
||||
};
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
isRuntimeRunning: () => true,
|
||||
authorization: () => 'test-secret',
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => service.snapshot().source.state === 'live');
|
||||
const snapshot = service.snapshot();
|
||||
assert.equal(snapshot.epoch, 'sing-box-1700000000000');
|
||||
assert.equal(snapshot.source.singBoxVersion, '1.14.0-rc.5');
|
||||
assert.equal(snapshot.source.singBoxApiVersion, 4);
|
||||
assert.equal(snapshot.connections[0].destination.domain, 'example.test');
|
||||
assert.deepEqual(requests, [
|
||||
['factory', 19091],
|
||||
['version', true, 'Bearer test-secret'],
|
||||
['started-at', true, 'Bearer test-secret'],
|
||||
['connections', 1_000_000_000n, 'Bearer test-secret'],
|
||||
['status', 1_000_000_000n, 'Bearer test-secret'],
|
||||
]);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
test('disabled collector stays cached and never constructs a native client', async () => {
|
||||
let factoryCalls = 0;
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: false,
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory: () => {
|
||||
factoryCalls += 1;
|
||||
throw new Error('must not connect');
|
||||
},
|
||||
});
|
||||
|
||||
service.start();
|
||||
assert.equal(service.snapshot().source.state, 'disabled');
|
||||
assert.equal(factoryCalls, 0);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
test('a native runtime materialization error is cached as incompatible without constructing a client', async () => {
|
||||
let factoryCalls = 0;
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: false,
|
||||
unavailableError: 'secret file unavailable at https://private.example/path',
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory: () => {
|
||||
factoryCalls += 1;
|
||||
throw new Error('must not connect');
|
||||
},
|
||||
});
|
||||
|
||||
service.start();
|
||||
assert.equal(service.snapshot().source.state, 'incompatible');
|
||||
assert.equal(service.snapshot().source.error, 'secret file unavailable at [endpoint]');
|
||||
assert.equal(factoryCalls, 0);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
for (const incompatible of [
|
||||
{ version: '1.13.18', apiVersion: 4 },
|
||||
{ version: '1.14.0-rc.5', apiVersion: 5 },
|
||||
]) {
|
||||
test(`collector rejects unqualified sing-box ${incompatible.version} API ${incompatible.apiVersion}`, async () => {
|
||||
let startedAtCalls = 0;
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory: () => ({
|
||||
async getVersion() { return incompatible; },
|
||||
async getStartedAt() {
|
||||
startedAtCalls += 1;
|
||||
return { startedAt: 1n };
|
||||
},
|
||||
subscribeConnections() { throw new Error('must not subscribe'); },
|
||||
subscribeStatus() { throw new Error('must not subscribe'); },
|
||||
}),
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => service.snapshot().source.state === 'incompatible');
|
||||
assert.equal(service.snapshot().source.singBoxVersion, incompatible.version);
|
||||
assert.equal(service.snapshot().source.singBoxApiVersion, incompatible.apiVersion);
|
||||
assert.equal(startedAtCalls, 0);
|
||||
await service.stop();
|
||||
});
|
||||
}
|
||||
|
||||
test('collector reconnects when either RC5 stream ends and cancels its sibling', async () => {
|
||||
let factoryCalls = 0;
|
||||
let authorizationCalls = 0;
|
||||
const attachedSecrets = [];
|
||||
let firstStatusSignal;
|
||||
const pending = (signal) => (async function* stream() {
|
||||
await new Promise((_, reject) => {
|
||||
const abort = () => reject(signal.reason || new Error('aborted'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}());
|
||||
const clientFactory = () => {
|
||||
factoryCalls += 1;
|
||||
const attempt = factoryCalls;
|
||||
return {
|
||||
async getVersion(_input, { headers }) {
|
||||
attachedSecrets.push(headers.authorization);
|
||||
return { version: '1.14.0-rc.5', apiVersion: 4 };
|
||||
},
|
||||
async getStartedAt() {
|
||||
return { startedAt: 1_700_000_000_000n };
|
||||
},
|
||||
subscribeConnections(_input, { signal }) {
|
||||
return attempt === 1 ? (async function* ended() {})() : pending(signal);
|
||||
},
|
||||
subscribeStatus(_input, { signal }) {
|
||||
if (attempt === 1) firstStatusSignal = signal;
|
||||
return pending(signal);
|
||||
},
|
||||
};
|
||||
};
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
isRuntimeRunning: () => true,
|
||||
authorization: () => `secret-${++authorizationCalls}`,
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => factoryCalls >= 2, 2_000);
|
||||
assert.equal(firstStatusSignal.aborted, true);
|
||||
assert.equal(factoryCalls, 2);
|
||||
assert.deepEqual(attachedSecrets, ['Bearer secret-1', 'Bearer secret-2']);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
test('a failed lifecycle projection is retained across one collector reconnect', async () => {
|
||||
let factoryCalls = 0;
|
||||
const projected = [];
|
||||
const idleStream = (signal) => (async function* stream() {
|
||||
await new Promise((_, reject) => {
|
||||
const abort = () => reject(signal.reason || new Error('aborted'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}());
|
||||
const connection = {
|
||||
id: 'projection',
|
||||
inbound: 'tproxy-in',
|
||||
inboundType: 'tproxy',
|
||||
network: 'tcp',
|
||||
source: '192.168.50.7:54000',
|
||||
destination: '203.0.113.10:443',
|
||||
domain: 'example.test',
|
||||
protocol: 'tls',
|
||||
createdAt: 1_700_000_000_000n,
|
||||
closedAt: 0n,
|
||||
uplinkTotal: 1n,
|
||||
downlinkTotal: 0n,
|
||||
outbound: 'channel-selector',
|
||||
outboundType: 'selector',
|
||||
rule: 'final',
|
||||
chainList: ['channel-primary', 'channel-selector'],
|
||||
};
|
||||
const clientFactory = () => {
|
||||
factoryCalls += 1;
|
||||
const attempt = factoryCalls;
|
||||
return {
|
||||
async getVersion() {
|
||||
return { version: '1.14.0-rc.5', apiVersion: 4 };
|
||||
},
|
||||
async getStartedAt() {
|
||||
return { startedAt: 1_700_000_000_000n };
|
||||
},
|
||||
subscribeConnections(_input, { signal }) {
|
||||
return (async function* stream() {
|
||||
yield { reset: true, events: [{ type: 0, id: connection.id, connection }] };
|
||||
await new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason || new Error('aborted')), { once: true });
|
||||
});
|
||||
}());
|
||||
},
|
||||
subscribeStatus(_input, { signal }) {
|
||||
return attempt === 1
|
||||
? idleStream(signal)
|
||||
: (async function* stream() {
|
||||
while (!signal.aborted) {
|
||||
yield { connectionsIn: 1, uplinkTotal: 1n, downlinkTotal: 0n };
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}());
|
||||
},
|
||||
};
|
||||
};
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
gateway: true,
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory,
|
||||
onProjection: async (batch) => {
|
||||
projected.push(batch.connections[0]?.traffic.uploadBytes || 'heartbeat');
|
||||
if (projected.length === 1) throw new Error('writer unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => factoryCalls >= 2
|
||||
&& projected.includes('heartbeat')
|
||||
&& service.snapshot().source.state === 'live', 2_000);
|
||||
assert.equal(projected[0], '1');
|
||||
assert.ok(projected.filter((value) => value === '1').length >= 2);
|
||||
assert.ok(projected.includes('heartbeat'));
|
||||
assert.equal(factoryCalls, 2);
|
||||
assert.equal(service.snapshot().source.error, null);
|
||||
assert.equal(service.snapshot().connections[0].route.kind, 'vpn');
|
||||
await service.stop();
|
||||
});
|
||||
Reference in New Issue
Block a user