Files
harbor-net/test/server/device-traffic.test.js
T
dokril e774486b99
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s
Add device traffic counters and ambiguous MAC detection
2026-08-07 14:29:51 +03:00

178 lines
7.2 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
buildTrafficRuleCommands,
createDeviceTrafficService,
parseTrafficCounters,
selectTrafficDevices,
} from '../../src/server/services/deviceTrafficService.js';
const uploadChain = 'VPN_PROXY_TRAFFIC_UP';
const downloadChain = 'VPN_PROXY_TRAFFIC_DOWN';
const dataplaneSource = fs.readFileSync(
path.resolve(import.meta.dirname, '../../src/server/dataplane.js'),
'utf8',
);
const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') => ({
ip,
mac,
interface: deviceInterface,
});
test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => {
assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/);
assert.match(dataplaneSource, /ready = true;[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/);
assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/);
});
test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
const selected = selectTrafficDevices([
observation('192.168.50.7'),
observation('192.168.50.7'),
observation('192.168.50.8', '00:11:22:33:44:66'),
observation('192.168.50.9', '00:11:22:33:44:66'),
observation('192.168.50.10', '00:11:22:33:44:77', 'bad interface'),
observation('192.168.50.11', '00:11:22:33:44:88', 'br-docker0'),
observation('2001:db8::7', '00:11:22:33:44:88'),
]);
assert.equal(selected.length, 1);
assert.deepEqual(
{ ip: selected[0].ip, mac: selected[0].mac, interface: selected[0].interface },
observation('192.168.50.7'),
);
assert.match(selected[0].key, /^[a-f0-9]{16}$/);
});
test('traffic rules mirror public upload and download semantics without routing targets', () => {
const [device] = selectTrafficDevices([observation('192.168.50.7')]);
const commands = buildTrafficRuleCommands({
devices: [device],
bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'],
uploadChain,
downloadChain,
slot: 'A',
});
const args = commands.map(([, commandArgs]) => commandArgs);
assert.ok(args.some((value) => value.join(' ') === '-w 1 -t raw -A VPN_PROXY_TRAFFIC_UP_A -d 10.0.0.0/8 -j RETURN'));
assert.ok(args.some((value) => value.join(' ') === '-w 1 -t mangle -A VPN_PROXY_TRAFFIC_DOWN_A -s 10.0.0.0/8 -j RETURN'));
assert.ok(args.some((value) => (
value.includes('--mac-source') && value.includes('00:11:22:33:44:55')
&& value.includes('-i') && value.includes('eth0') && value.includes('-s')
)));
assert.ok(args.some((value) => (
value.some((part) => part.startsWith('harbor-traffic:')) && value.includes('-o')
&& value.includes('eth0') && value.includes('-d') && value.includes('192.168.50.7')
)));
assert.ok(args.filter((value) => value.includes('-j')).every((value) => value[value.indexOf('-j') + 1] === 'RETURN'));
const uploadDeviceIndex = args.findIndex((value) => value.some((part) => part.endsWith(':upload')));
const downloadDeviceIndex = args.findIndex((value) => value.some((part) => part.endsWith(':download')));
assert.ok(args.filter((value) => value.includes('-d') && value.includes('10.0.0.0/8'))
.every((value) => args.indexOf(value) < uploadDeviceIndex));
assert.ok(args.filter((value) => value.includes('-s') && value.includes('10.0.0.0/8'))
.every((value) => args.indexOf(value) < downloadDeviceIndex));
});
test('counter parser preserves exact uint64 byte strings', () => {
const counters = parseTrafficCounters(
'[7:9007199254740993] -A VPN_PROXY_TRAFFIC_UP_A -s 192.168.50.7/32 -m comment --comment "harbor-traffic:0123456789abcdef:upload" -j RETURN\n',
'VPN_PROXY_TRAFFIC_UP_A',
);
assert.equal(counters.get('0123456789abcdef:upload'), '9007199254740993');
});
test('traffic service preserves active rules and snapshot when replacement fails', async () => {
let observed = {
observedAt: '2026-08-07T12:00:00.000Z',
observations: [observation('192.168.50.7')],
error: null,
};
const firstDevice = selectTrafficDevices(observed.observations)[0];
const calls = [];
let failReplacement = false;
let failCounters = false;
const generations = ['boot', 'rules-a', 'rules-b'];
const run = (command, args, options) => {
calls.push([command, args, options]);
if (command === 'iptables-save') {
if (failCounters) return { status: null, stdout: '', stderr: '', error: new Error('counter read timed out') };
const direction = args.includes('raw') ? 'upload' : 'download';
const chain = direction === 'upload' ? `${uploadChain}_A` : `${downloadChain}_A`;
const bytes = direction === 'upload' ? '1200' : '3400';
return {
status: 0,
stdout: `[1:${bytes}] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:${direction}" -j RETURN\n`,
stderr: '',
};
}
if (failReplacement && args.includes('-R') && args.includes(downloadChain)) {
return { status: 1, stdout: '', stderr: 'cannot switch download rules' };
}
return { status: 0, stdout: '', stderr: '' };
};
const service = createDeviceTrafficService({
observe: async () => observed,
uploadChain,
downloadChain,
bypassCidrs: ['10.0.0.0/8'],
run,
nextGeneration: () => generations.shift(),
});
const first = await service.refresh();
assert.equal(first.generation, 'rules-a');
assert.deepEqual(first.devices, [{
ip: '192.168.50.7',
mac: '00:11:22:33:44:55',
interface: 'eth0',
uploadBytes: '1200',
downloadBytes: '3400',
}]);
const switchCallsBefore = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length;
await service.refresh();
const switchCallsAfter = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length;
assert.equal(switchCallsAfter, switchCallsBefore);
observed = {
observedAt: '2026-08-07T12:01:00.000Z',
observations: [observation('192.168.50.8')],
error: null,
};
failReplacement = true;
const failed = await service.refresh();
assert.equal(failed.generation, 'rules-a');
assert.match(failed.source.error, /cannot switch download rules/);
assert.deepEqual(failed.devices, first.devices);
const uploadSwitches = calls
.filter(([, args]) => args.includes('-R') && args.includes(uploadChain))
.map(([, args]) => args.at(-1));
assert.deepEqual(uploadSwitches, [`${uploadChain}_B`, `${uploadChain}_A`]);
failReplacement = false;
observed = {
observedAt: '2026-08-07T12:02:00.000Z',
observations: [],
error: 'neighbors unavailable',
};
const stale = await service.refresh();
assert.equal(stale.generation, 'rules-a');
assert.equal(stale.source.error, 'neighbors unavailable');
assert.deepEqual(stale.devices, first.devices);
failCounters = true;
observed = {
observedAt: '2026-08-07T12:03:00.000Z',
observations: [observation('192.168.50.7')],
error: null,
};
const timedOut = await service.refresh();
assert.equal(timedOut.observedAt, '2026-08-07T12:02:00.000Z');
assert.match(timedOut.source.error, /counter read timed out/);
assert.deepEqual(timedOut.devices, first.devices);
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
});