Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createLiveTrafficRoute,
|
||||
enrichLiveTrafficDeviceLabels,
|
||||
} from '../../dist/server/http/routes/liveTrafficRoute.js';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.rawPayload = payload;
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
apiVersion: 1,
|
||||
epoch: 'sing-box-1700000000000',
|
||||
sequence: 7,
|
||||
observedAt: '2026-08-31T10:00:00.000Z',
|
||||
capabilities: {
|
||||
lifecycle: true,
|
||||
deviceAttribution: false,
|
||||
applicationAttribution: false,
|
||||
},
|
||||
source: {
|
||||
transport: 'native',
|
||||
state: 'live',
|
||||
completeness: 'lifecycle',
|
||||
singBoxVersion: '1.14.0-rc.5',
|
||||
singBoxApiVersion: 4,
|
||||
error: null,
|
||||
unattributedUploadBytes: '0',
|
||||
unattributedDownloadBytes: '0',
|
||||
},
|
||||
summary: {
|
||||
active: 0,
|
||||
recent: 0,
|
||||
visible: 0,
|
||||
recognized: 0,
|
||||
unresolved: 0,
|
||||
unresolvedOrigin: 0,
|
||||
truncated: false,
|
||||
},
|
||||
connections: [],
|
||||
};
|
||||
|
||||
test('split Gateway control reads the cached socket while combined Gateway keeps no collector', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(index, /const liveTraffic = clientLiveTraffic \|\| \(remoteDataplane \? \{[\s\S]*observeLiveTraffic\(\)[\s\S]*\} : null\)/);
|
||||
assert.match(index, /createLiveTrafficRoute\(\{[\s\S]*traffic: liveTraffic,[\s\S]*deviceInventory: remoteDataplane \? deviceInventory : null/);
|
||||
assert.match(index, /clientLiveTraffic\?\.start\(\)/);
|
||||
assert.doesNotMatch(index, /liveTraffic\?\.start\(\)/);
|
||||
});
|
||||
|
||||
test('GET returns the injected cached snapshot without another data-source operation', async () => {
|
||||
let snapshots = 0;
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: {
|
||||
snapshot() {
|
||||
snapshots += 1;
|
||||
return snapshot;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live?ignored=1' }, res), true);
|
||||
assert.equal(snapshots, 1);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.headers, { 'content-type': 'application/json; charset=utf-8' });
|
||||
assert.deepEqual(res.payload, snapshot);
|
||||
});
|
||||
|
||||
test('async Gateway snapshots are validated and known dev labels are enriched without changing summary', async () => {
|
||||
const gatewaySnapshot = {
|
||||
...snapshot,
|
||||
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
|
||||
summary: {
|
||||
active: 300,
|
||||
recent: 0,
|
||||
visible: 256,
|
||||
recognized: 300,
|
||||
unresolved: 0,
|
||||
unresolvedOrigin: 299,
|
||||
truncated: true,
|
||||
},
|
||||
connections: Array.from({ length: 256 }, (_, index) => ({
|
||||
id: `connection-${String(index).padStart(3, '0')}`,
|
||||
startedAt: new Date(Date.parse(snapshot.observedAt) - index * 1000).toISOString(),
|
||||
closedAt: null,
|
||||
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
||||
network: 'tcp',
|
||||
protocol: 'tls',
|
||||
source: { ip: index === 0 ? '192.168.50.7' : '192.168.50.8', port: 50_000 + index },
|
||||
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
||||
origin: index === 0
|
||||
? { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' }
|
||||
: { kind: 'unknown', id: null, label: 'Неизвестное устройство', provenance: 'unknown' },
|
||||
route: {
|
||||
kind: 'vpn',
|
||||
scope: 'local-sing-box',
|
||||
outbound: 'proxy',
|
||||
outboundType: 'selector',
|
||||
chain: ['proxy'],
|
||||
rule: 'default',
|
||||
},
|
||||
traffic: {
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
uploadBytesPerSecond: '1',
|
||||
downloadBytesPerSecond: '2',
|
||||
},
|
||||
})),
|
||||
};
|
||||
const original = structuredClone(gatewaySnapshot);
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: { snapshot: async () => gatewaySnapshot },
|
||||
deviceInventory: {
|
||||
snapshot: () => ({
|
||||
devices: [{
|
||||
id: 'dev_0011223344556677',
|
||||
alias: 'Гостиная',
|
||||
hostname: 'tv.local',
|
||||
ip: '192.168.50.7',
|
||||
}],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live' }, res), true);
|
||||
assert.equal(res.payload.connections[0].origin.label, 'Гостиная');
|
||||
assert.equal(res.payload.connections[1].origin.kind, 'unknown');
|
||||
assert.deepEqual(res.payload.summary, gatewaySnapshot.summary);
|
||||
assert.deepEqual(gatewaySnapshot, original);
|
||||
});
|
||||
|
||||
test('device label enrichment follows alias, hostname and IP without identifying unknown origins', () => {
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
startedAt: snapshot.observedAt,
|
||||
closedAt: null,
|
||||
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
||||
network: 'tcp',
|
||||
protocol: 'tls',
|
||||
source: { ip: '192.168.50.7', port: 50_000 },
|
||||
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
||||
origin: { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' },
|
||||
route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'proxy', outboundType: 'selector', chain: ['proxy'], rule: 'default' },
|
||||
traffic: { uploadBytes: '1', downloadBytes: '2', uploadBytesPerSecond: '0', downloadBytesPerSecond: '0' },
|
||||
};
|
||||
const source = {
|
||||
...snapshot,
|
||||
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
|
||||
summary: { ...snapshot.summary, active: 1, visible: 1, recognized: 1 },
|
||||
connections: [connection],
|
||||
};
|
||||
const labels = (device) => enrichLiveTrafficDeviceLabels(source, { devices: [device] })
|
||||
.connections[0].origin.label;
|
||||
|
||||
assert.equal(labels({ id: connection.origin.id, alias: ' ТВ ', hostname: 'tv.local', ip: '192.168.50.7' }), 'ТВ');
|
||||
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: 'tv.local', ip: '192.168.50.7' }), 'tv.local');
|
||||
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: null, ip: '192.168.50.7' }), '192.168.50.7');
|
||||
assert.equal(labels({ id: 'dev_ffffffffffffffff', alias: 'Чужой', ip: connection.source.ip }), connection.origin.label);
|
||||
const unknown = { ...source, connections: [{ ...connection, origin: { kind: 'unknown', id: null, label: 'Неизвестно', provenance: 'unknown' } }] };
|
||||
assert.equal(enrichLiveTrafficDeviceLabels(unknown, { devices: [{ ...connection.origin, id: 'dev_0011223344556677', alias: 'Не угадывать' }] }).connections[0].origin.label, 'Неизвестно');
|
||||
});
|
||||
|
||||
test('route rejects malformed cached snapshots before responding', async () => {
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: { snapshot: async () => ({ ...snapshot, apiVersion: 2 }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
|
||||
/apiVersion 1/,
|
||||
);
|
||||
});
|
||||
|
||||
test('route ignores other paths and rejects mutation methods without reading the cache', async () => {
|
||||
let snapshots = 0;
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: { snapshot: () => { snapshots += 1; return snapshot; } },
|
||||
});
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/history' }, response()), false);
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'POST', url: '/api/traffic/live' }, response()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
assert.equal(snapshots, 0);
|
||||
});
|
||||
|
||||
test('route is unavailable when no traffic collector exists', async () => {
|
||||
const route = createLiveTrafficRoute({ traffic: null });
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user