Files
harbor-net/test/server/domain-traffic.test.js
dokril ab14fc979e
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 7s
Clarify VPN and Direct traffic metrics in Grafana dashboard
2026-09-01 07:01:26 +03:00

361 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
classifyDomain,
createDomainTrafficService,
} from '../../dist/server/services/domainTrafficService.js';
import { deviceId } from '../../dist/server/services/deviceInventoryService.js';
const mac = '00:11:22:33:44:55';
const id = deviceId(mac);
const device = { ip: '192.168.50.7', mac, alias: 'MacBook' };
const connection = (connectionId, type, host, upload, download, sourceIP = device.ip, chains = ['vpn-out']) => ({
id: connectionId,
metadata: { type, host, sourceIP },
upload,
download,
chains,
});
const nativeConnection = (connectionId, uploadBytes, downloadBytes, overrides = {}) => ({
id: connectionId,
startedAt: '2026-08-31T10:00:00.000Z',
closedAt: null,
inbound: { tag: 'tproxy-in', type: 'tproxy' },
network: 'tcp',
protocol: 'tls',
source: { ip: device.ip, port: 54_000 },
destination: { domain: 'example.com', ip: null, port: 443, provenance: 'sing-box' },
origin: { kind: 'device', id, label: 'MacBook', provenance: 'source-ip' },
route: {
kind: 'vpn',
scope: 'local-sing-box',
outbound: 'channel-selector',
outboundType: 'selector',
chain: ['channel-primary', 'channel-selector'],
rule: 'final',
},
traffic: {
uploadBytes,
downloadBytes,
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
},
...overrides,
});
const nativeBatch = (connections, overrides = {}) => ({
epoch: 'sing-box-100',
observedAt: '2026-08-31T10:00:00.000Z',
reset: false,
connections,
closedIds: [],
...overrides,
});
test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => {
let response = { connections: [
connection('direct', 'tproxy/tproxy-in', 'one.example', 10, 100, device.ip, ['direct']),
connection('vpn', 'tproxy/tproxy-in', 'two.example', 20, 200),
connection('unknown', 'tproxy/tproxy-in', 'three.example', 30, 300, device.ip, []),
connection('unmapped', 'tproxy/tproxy-in', 'four.example', 40, 400, '192.168.50.99'),
] };
const service = createDomainTrafficService({
observe: async () => response,
devices: () => [device],
});
await service.refresh();
response = { connections: [
connection('direct', 'tproxy/tproxy-in', 'one.example', 15, 110, device.ip, ['direct']),
connection('vpn', 'tproxy/tproxy-in', 'two.example', 25, 220, device.ip, ['direct']),
connection('unknown', 'tproxy/tproxy-in', 'three.example', 35, 330, device.ip, [null]),
connection('unmapped', 'tproxy/tproxy-in', 'four.example', 50, 500, '192.168.50.99'),
] };
await service.refresh();
await service.refresh();
assert.deepEqual(service.snapshot().routes, [
{ deviceId: id, source: 'gateway', outbound: 'direct', uploadBytes: '20', downloadBytes: '130' },
{ deviceId: id, source: 'gateway', outbound: 'unknown', uploadBytes: '35', downloadBytes: '330' },
{ deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: '20', downloadBytes: '200' },
]);
assert.deepEqual(service.snapshot().tracked, [
{ source: 'gateway', outbound: 'direct', uploadBytes: '20', downloadBytes: '130' },
{ source: 'gateway', outbound: 'unknown', uploadBytes: '35', downloadBytes: '330' },
{ source: 'gateway', outbound: 'vpn', uploadBytes: '70', downloadBytes: '700' },
]);
});
test('domain traffic accumulates connection deltas by device, service and source', async () => {
let observedAt = new Date('2026-08-08T10:00:00.000Z');
let response = { connections: [
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 10, 100),
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 20, 200),
connection('hostless', 'tproxy/tproxy-in', '', 5, 50),
connection('diagnostics', 'mixed/diagnostics-vpn-in', 'example.com', 30, 300),
connection('unknown-device', 'tproxy/tproxy-in', 'example.net', 40, 400, '192.168.50.99'),
] };
const service = createDomainTrafficService({
observe: async () => response,
devices: () => [device],
now: () => observedAt,
});
await service.refresh();
response = { connections: [
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 15, 130),
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 22, 260),
connection('hostless', 'tproxy/tproxy-in', '', 7, 70),
] };
observedAt = new Date('2026-08-08T10:00:02.000Z');
await service.refresh();
await service.refresh();
assert.deepEqual(service.snapshot().series, [
{
deviceId: id,
domain: 'chatgpt.com',
service: 'OpenAI / ChatGPT',
source: 'proxy',
uploadBytes: '22',
downloadBytes: '260',
},
{
deviceId: id,
domain: 'googlevideo.com',
service: 'YouTube',
source: 'gateway',
uploadBytes: '15',
downloadBytes: '130',
},
{
deviceId: id,
domain: '_unknown',
service: 'Не распознано',
source: 'gateway',
uploadBytes: '7',
downloadBytes: '70',
},
]);
assert.deepEqual(service.snapshot().routes, [
{
deviceId: id,
source: 'gateway',
outbound: 'vpn',
uploadBytes: '22',
downloadBytes: '200',
},
{
deviceId: id,
source: 'proxy',
outbound: 'vpn',
uploadBytes: '22',
downloadBytes: '260',
},
]);
assert.deepEqual(service.snapshot().attributionEvents, {
unresolved_host: '1',
unknown_device: '1',
unsupported_source: '1',
});
assert.equal(service.snapshot().observedAt, observedAt.toISOString());
assert.equal(service.snapshot().source.error, null);
response = { connections: [] };
await service.refresh();
assert.equal(service.snapshot().series[1].downloadBytes, '130');
});
test('a hostless connection can become classified without counting its bytes twice', async () => {
let response = { connections: [
connection('video', 'tproxy/tproxy-in', '', 10, 100),
] };
const service = createDomainTrafficService({
observe: async () => response,
devices: () => [device],
});
await service.refresh();
response = { connections: [
connection('video', 'tproxy/tproxy-in', 'r2.googlevideo.com', 15, 130),
] };
await service.refresh();
assert.deepEqual(
Object.fromEntries(service.snapshot().series.map((series) => [series.domain, {
uploadBytes: series.uploadBytes,
downloadBytes: series.downloadBytes,
}])),
{
_unknown: { uploadBytes: '10', downloadBytes: '100' },
'googlevideo.com': { uploadBytes: '5', downloadBytes: '30' },
},
);
assert.deepEqual(service.snapshot().attributionEvents, {
unresolved_host: '1',
unknown_device: '0',
unsupported_source: '0',
});
});
test('domain traffic is bounded and keeps the last good snapshot on source failure', async () => {
let fail = false;
const service = createDomainTrafficService({
observe: async () => {
if (fail) throw new Error('Clash API unavailable');
return { connections: [
connection('first', 'tproxy/tproxy-in', 'one.example', 1, 10),
connection('second', 'tproxy/tproxy-in', 'two.example', 2, 20),
] };
},
devices: () => [device],
maxSeries: 3,
});
await service.refresh();
await service.refresh();
assert.deepEqual(
Object.fromEntries(service.snapshot().series.map(({ domain, downloadBytes }) => [domain, downloadBytes])),
{ 'one.example': '10', _other: '20' },
);
assert.equal(service.snapshot().overflowConnections, '1');
assert.equal(service.snapshot().series.find(({ domain }) => domain === '_other').deviceId, '_other');
assert.ok(service.snapshot().series.length <= 3);
fail = true;
await assert.rejects(service.refresh(), /Clash API unavailable/);
assert.equal(service.snapshot().series.find(({ domain }) => domain === 'one.example').downloadBytes, '10');
assert.equal(service.snapshot().source.error, 'Clash API unavailable');
});
test('an invalid connection rejects the whole snapshot without double-counting a retry', async () => {
let response = { connections: [
connection('valid', 'tproxy/tproxy-in', 'one.example', 10, 100),
{ id: 'invalid', upload: -1, download: 0, metadata: {} },
] };
const service = createDomainTrafficService({
observe: async () => response,
devices: () => [device],
});
await assert.rejects(service.refresh(), /невалидный domain traffic counter/);
assert.deepEqual(service.snapshot().tracked, []);
assert.deepEqual(service.snapshot().routes, []);
assert.deepEqual(service.snapshot().series, []);
response = { connections: [connection('valid', 'tproxy/tproxy-in', 'one.example', 10, 100)] };
await service.refresh();
assert.equal(service.snapshot().tracked[0].downloadBytes, '100');
assert.equal(service.snapshot().routes[0].downloadBytes, '100');
assert.equal(service.snapshot().series[0].downloadBytes, '100');
});
test('domain classification normalizes known services and rejects IP or malformed labels', () => {
assert.deepEqual(classifyDomain('WWW.YouTube.com.'), { domain: 'youtube.com', service: 'YouTube' });
assert.deepEqual(classifyDomain('api.example.org'), { domain: 'api.example.org', service: 'api.example.org' });
assert.equal(classifyDomain('192.0.2.1'), null);
assert.equal(classifyDomain('broken_label.example'), null);
});
test('failover activity is zero-work while disabled and uses the existing connection poll', async () => {
let observedAt = new Date('2026-08-19T10:00:00.000Z');
let response = { connections: [
connection('work', 'tproxy/tproxy-in', 'r1.googlevideo.com', 0, 0, device.ip, ['channel-primary', 'channel-selector']),
connection('probe', 'mixed/diagnostics-primary-in', 'youtube.com', 0, 10_000, device.ip, ['channel-primary']),
] };
const service = createDomainTrafficService({
observe: async () => response,
devices: () => [device],
now: () => observedAt,
});
await service.refresh();
assert.equal(service.activitySnapshot(100), null);
service.enableActivity();
observedAt = new Date('2026-08-19T10:00:02.000Z');
response.connections[0].download = 2_000;
response.connections[1].download = 99_000;
await service.refresh();
const active = service.activitySnapshot(500);
assert.equal(active.state, 'active');
assert.equal(active.totalBytesPerSecond, 1_000);
assert.equal(active.transmittingConnections, 1);
assert.equal(active.blockers[0].device, 'MacBook');
assert.equal(active.blockers[0].service, 'YouTube');
observedAt = new Date('2026-08-19T10:00:14.000Z');
await service.refresh();
assert.equal(service.activitySnapshot(500).state, 'quiet');
service.disableActivity();
assert.equal(service.activitySnapshot(500), null);
});
test('native lifecycle batches keep decimal precision and dedupe a same-epoch reconnect reset and final tail', () => {
let now = new Date('2026-08-31T10:00:00.000Z');
const service = createDomainTrafficService({
observe: () => ({ connections: [] }),
devices: () => [],
now: () => now,
});
const initial = nativeConnection('native', '9007199254740993', '10');
service.ingestNative(nativeBatch([initial], { reset: true }));
now = new Date('2026-08-31T10:00:01.000Z');
service.ingestNative(nativeBatch([initial], {
reset: true,
observedAt: now.toISOString(),
}));
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740993');
assert.equal(service.snapshot().source.activeConnections, 1);
service.enableActivity();
now = new Date('2026-08-31T10:00:02.000Z');
service.ingestNative(nativeBatch([
nativeConnection('native', '9007199254740998', '15', { closedAt: now.toISOString() }),
], {
observedAt: now.toISOString(),
closedIds: ['native'],
}));
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
assert.equal(service.snapshot().series[0].downloadBytes, '15');
assert.equal(service.snapshot().source.activeConnections, 0);
assert.equal(service.activitySnapshot(0).state, 'active');
now = new Date('2026-08-31T10:00:03.000Z');
service.ingestNative(nativeBatch([
nativeConnection('native', '9007199254740998', '15'),
], {
reset: true,
observedAt: now.toISOString(),
}));
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
assert.equal(service.snapshot().series[0].downloadBytes, '15');
assert.equal(service.snapshot().source.activeConnections, 1);
now = new Date('2026-08-31T10:00:14.000Z');
service.ingestNative(nativeBatch([], { observedAt: now.toISOString() }));
assert.equal(service.snapshot().observedAt, now.toISOString());
assert.equal(service.snapshot().source.activeConnections, 1);
assert.equal(service.activitySnapshot(0).state, 'quiet');
});
test('one native batch accounts every final tail before lifecycle and UI caps', () => {
const service = createDomainTrafficService({
observe: () => ({ connections: [] }),
devices: () => [],
});
const connections = Array.from({ length: 2_049 }, (_, index) => (
nativeConnection(`closed-${index}`, '1', '1', { closedAt: '2026-08-31T10:00:00.000Z' })
));
service.ingestNative(nativeBatch(connections, {
reset: true,
closedIds: connections.map(({ id: connectionId }) => connectionId),
}));
assert.equal(service.snapshot().series[0].uploadBytes, '2049');
assert.equal(service.snapshot().series[0].downloadBytes, '2049');
assert.equal(service.snapshot().source.activeConnections, 0);
});