Add device traffic counters and ambiguous MAC detection
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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));
|
||||
});
|
||||
Reference in New Issue
Block a user