Track proxy traffic separately in device inventory
This commit is contained in:
@@ -136,6 +136,8 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
||||
interface: 'eth0',
|
||||
uploadBytes: '9007199254740993',
|
||||
downloadBytes: '100',
|
||||
proxyUploadBytes: '1000',
|
||||
proxyDownloadBytes: '2000',
|
||||
}],
|
||||
};
|
||||
let trafficError = null;
|
||||
@@ -153,7 +155,13 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '100');
|
||||
assert.equal(snapshot.devices[0].trafficObservedAt, observedAt);
|
||||
assert.deepEqual(snapshot.source.traffic, { lastObservedAt: observedAt, error: null });
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '1000');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2000');
|
||||
assert.deepEqual(snapshot.source.traffic, {
|
||||
lastObservedAt: observedAt,
|
||||
error: null,
|
||||
proxy: { lastObservedAt: observedAt, error: null },
|
||||
});
|
||||
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||
@@ -161,11 +169,19 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
||||
|
||||
traffic = {
|
||||
...traffic,
|
||||
devices: [{ ...traffic.devices[0], uploadBytes: '9007199254740995', downloadBytes: '150' }],
|
||||
devices: [{
|
||||
...traffic.devices[0],
|
||||
uploadBytes: '9007199254740995',
|
||||
downloadBytes: '150',
|
||||
proxyUploadBytes: '1010',
|
||||
proxyDownloadBytes: '2050',
|
||||
}],
|
||||
};
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '1010');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2050');
|
||||
|
||||
service = createService();
|
||||
snapshot = await service.refresh();
|
||||
@@ -176,11 +192,19 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
||||
...traffic,
|
||||
epoch: 'epoch-b',
|
||||
generation: 'rules-b',
|
||||
devices: [{ ...traffic.devices[0], uploadBytes: '10', downloadBytes: '20' }],
|
||||
devices: [{
|
||||
...traffic.devices[0],
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
proxyUploadBytes: '3',
|
||||
proxyDownloadBytes: '4',
|
||||
}],
|
||||
};
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '170');
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
||||
|
||||
traffic = {
|
||||
...traffic,
|
||||
@@ -190,13 +214,114 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||
assert.match(snapshot.source.traffic.error, /уменьшился/);
|
||||
|
||||
traffic = {
|
||||
...traffic,
|
||||
devices: [{
|
||||
...traffic.devices[0],
|
||||
uploadBytes: '12',
|
||||
downloadBytes: '25',
|
||||
proxyUploadBytes: '2',
|
||||
proxyDownloadBytes: '4',
|
||||
}],
|
||||
};
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741007');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '175');
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
||||
assert.equal(snapshot.source.traffic.error, null);
|
||||
assert.match(snapshot.source.traffic.proxy.error, /уменьшился/);
|
||||
|
||||
trafficError = new Error('traffic unavailable');
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741007');
|
||||
assert.equal(snapshot.source.traffic.lastObservedAt, observedAt);
|
||||
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||
});
|
||||
|
||||
test('legacy dataplane samples preserve saved proxy totals while Gateway totals keep advancing', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-legacy-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const neighbor = {
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
||||
};
|
||||
let row = {
|
||||
mac,
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
proxyUploadBytes: '30',
|
||||
proxyDownloadBytes: '40',
|
||||
};
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => neighbor,
|
||||
observeTraffic: () => ({
|
||||
epoch: 'epoch-a', generation: 'rules-a', observedAt, source: { error: null }, devices: [row],
|
||||
}),
|
||||
});
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '30');
|
||||
row = { mac, uploadBytes: '15', downloadBytes: '27' };
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '15');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '27');
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '30');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '40');
|
||||
assert.equal(snapshot.source.traffic.proxy.error, null);
|
||||
});
|
||||
|
||||
test('a proxy regression rejects every device in that proxy sample atomically', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-atomic-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
const macs = ['00:11:22:33:44:55', '00:11:22:33:44:66'];
|
||||
const neighbor = {
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: macs.map((mac, index) => ({
|
||||
ip: `192.168.50.${index + 7}`, mac, interface: 'eth0', observedAt, active: true,
|
||||
})),
|
||||
};
|
||||
let rows = [
|
||||
{ mac: macs[0], uploadBytes: '10', downloadBytes: '20', proxyUploadBytes: '30', proxyDownloadBytes: '40' },
|
||||
{ mac: macs[1], uploadBytes: '50', downloadBytes: '60', proxyUploadBytes: '70', proxyDownloadBytes: '80' },
|
||||
];
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => neighbor,
|
||||
observeTraffic: () => ({
|
||||
epoch: 'epoch-a', generation: 'rules-a', observedAt, source: { error: null }, devices: rows,
|
||||
}),
|
||||
});
|
||||
await service.refresh();
|
||||
const before = structuredClone(store.read().traffic.proxy);
|
||||
rows = [
|
||||
{ mac: macs[0], uploadBytes: '11', downloadBytes: '22', proxyUploadBytes: '35', proxyDownloadBytes: '45' },
|
||||
{ mac: macs[1], uploadBytes: '52', downloadBytes: '63', proxyUploadBytes: '69', proxyDownloadBytes: '80' },
|
||||
];
|
||||
const snapshot = await service.refresh();
|
||||
const after = store.read().traffic.proxy;
|
||||
assert.deepEqual(after.baselinesByMac, before.baselinesByMac);
|
||||
assert.deepEqual(after.totalsByMac, before.totalsByMac);
|
||||
assert.match(snapshot.source.traffic.proxy.error, /уменьшился/);
|
||||
assert.equal(snapshot.devices.find(({ mac }) => mac === macs[0]).uploadBytes, '11');
|
||||
});
|
||||
|
||||
test('pinned device policy persists, reconciles the full set, and keeps the last applied mode on failure', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
@@ -517,3 +642,57 @@ test('device inventory backs up malformed v2 traffic and re-baselines without do
|
||||
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
|
||||
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
|
||||
});
|
||||
|
||||
test('malformed proxy totals are backed up and an expired recovery marker is cleared', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-proxy-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'devices.json');
|
||||
const mac = '00:11:22:33:44:55';
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
devices: [{
|
||||
id: 'dev_0123456789abcdef',
|
||||
alias: '',
|
||||
pinned: false,
|
||||
mac,
|
||||
ip: '192.168.50.7',
|
||||
interface: 'eth0',
|
||||
firstSeenAt: '2026-06-01T12:00:00.000Z',
|
||||
lastSeenAt: '2026-06-01T12:00:00.000Z',
|
||||
source: 'neighbor',
|
||||
confidence: 'high',
|
||||
}],
|
||||
traffic: {
|
||||
baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [],
|
||||
proxy: {
|
||||
schemaVersion: 1,
|
||||
baselinesByMac: { [mac]: { epoch: 'epoch-a', uploadBytes: '1', downloadBytes: '2' } },
|
||||
totalsByMac: { [mac]: { uploadBytes: 'broken', downloadBytes: '4' } },
|
||||
rebaselineMacs: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const store = createJsonStore({
|
||||
filePath,
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
backupWhen: () => true,
|
||||
});
|
||||
assert.match(store.read().traffic.proxy.lastError, /proxy traffic checkpoint/);
|
||||
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
now: () => new Date('2026-08-07T12:00:00.000Z'),
|
||||
observe: () => ({ observedAt: '2026-08-07T12:00:00.000Z', observations: [], error: null }),
|
||||
observeTraffic: () => ({
|
||||
epoch: 'epoch-a', generation: 'rules-a', observedAt: '2026-08-07T12:00:00.000Z',
|
||||
source: { error: null }, devices: [],
|
||||
}),
|
||||
});
|
||||
const snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices.length, 0);
|
||||
assert.deepEqual(store.read().traffic.proxy.rebaselineMacs, []);
|
||||
assert.equal(snapshot.source.traffic.proxy.error, null);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildTrafficRuleCommands,
|
||||
buildTrafficRestore,
|
||||
createDeviceTrafficService,
|
||||
parseTrafficCounters,
|
||||
selectTrafficDevices,
|
||||
@@ -35,6 +35,7 @@ test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
||||
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('192.168.50.12', '00:11:22:33:44:99', 'eth+'),
|
||||
observation('2001:db8::7', '00:11:22:33:44:88'),
|
||||
]);
|
||||
|
||||
@@ -46,42 +47,43 @@ test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
||||
assert.match(selected[0].key, /^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
test('traffic rules mirror public upload and download semantics without routing targets', () => {
|
||||
test('traffic rules split local proxy traffic from public Gateway traffic in one restore batch', () => {
|
||||
const [device] = selectTrafficDevices([observation('192.168.50.7')]);
|
||||
const commands = buildTrafficRuleCommands({
|
||||
const restore = buildTrafficRestore({
|
||||
devices: [device],
|
||||
bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'],
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
slot: 'A',
|
||||
proxyPort: 8080,
|
||||
});
|
||||
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));
|
||||
const lines = restore.trim().split('\n');
|
||||
const proxyUploadJump = '-A VPN_PROXY_TRAFFIC_UP_A -p tcp --dport 8080 -m addrtype --dst-type LOCAL -j VPN_PROXY_TRAFFIC_UP_A_P';
|
||||
const proxyUploadReturn = '-A VPN_PROXY_TRAFFIC_UP_A -p tcp --dport 8080 -m addrtype --dst-type LOCAL -j RETURN';
|
||||
const proxyDownloadJump = '-A VPN_PROXY_TRAFFIC_DOWN_A -p tcp --sport 8080 -m addrtype --src-type LOCAL -j VPN_PROXY_TRAFFIC_DOWN_A_P';
|
||||
const proxyDownloadReturn = '-A VPN_PROXY_TRAFFIC_DOWN_A -p tcp --sport 8080 -m addrtype --src-type LOCAL -j RETURN';
|
||||
assert.ok(lines.includes(proxyUploadJump));
|
||||
assert.ok(lines.includes(proxyUploadReturn));
|
||||
assert.ok(lines.indexOf(proxyUploadJump) < lines.indexOf(proxyUploadReturn));
|
||||
assert.ok(lines.includes(proxyDownloadJump));
|
||||
assert.ok(lines.includes(proxyDownloadReturn));
|
||||
assert.ok(lines.indexOf(proxyDownloadJump) < lines.indexOf(proxyDownloadReturn));
|
||||
assert.match(restore, /-A VPN_PROXY_TRAFFIC_UP_A_P .*--mac-source 00:11:22:33:44:55 .*harbor-traffic:[a-f0-9]{16}:proxy-upload/);
|
||||
assert.match(restore, /-A VPN_PROXY_TRAFFIC_DOWN_A_P .*192\.168\.50\.7 .*harbor-traffic:[a-f0-9]{16}:proxy-download/);
|
||||
assert.ok(lines.indexOf('-A VPN_PROXY_TRAFFIC_UP_A -d 10.0.0.0/8 -j RETURN')
|
||||
< lines.findIndex((line) => line.endsWith(':upload -j RETURN')));
|
||||
assert.ok(lines.indexOf('-A VPN_PROXY_TRAFFIC_DOWN_A -s 10.0.0.0/8 -j RETURN')
|
||||
< lines.findIndex((line) => line.endsWith(':download -j RETURN')));
|
||||
assert.doesNotMatch(restore, /TPROXY|DNAT|SNAT|REDIRECT/);
|
||||
});
|
||||
|
||||
test('counter parser preserves exact uint64 byte strings', () => {
|
||||
test('counter parser preserves exact uint64 strings and sums TCP plus UDP proxy rules', () => {
|
||||
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',
|
||||
'[7:9007199254740993] -A VPN_PROXY_TRAFFIC_UP_A_P -p tcp -m comment --comment "harbor-traffic:0123456789abcdef:proxy-upload" -j RETURN\n'
|
||||
+ '[3:9] -A VPN_PROXY_TRAFFIC_UP_A_P -p udp -m comment --comment "harbor-traffic:0123456789abcdef:proxy-upload" -j RETURN\n',
|
||||
'VPN_PROXY_TRAFFIC_UP_A_P',
|
||||
);
|
||||
assert.equal(counters.get('0123456789abcdef:upload'), '9007199254740993');
|
||||
assert.equal(counters.get('0123456789abcdef:proxy-upload'), '9007199254741002');
|
||||
});
|
||||
|
||||
test('traffic service preserves active rules and snapshot when replacement fails', async () => {
|
||||
@@ -102,9 +104,12 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
||||
const direction = args.includes('raw') ? 'upload' : 'download';
|
||||
const chain = direction === 'upload' ? `${uploadChain}_A` : `${downloadChain}_A`;
|
||||
const bytes = direction === 'upload' ? '1200' : '3400';
|
||||
const proxyDirection = direction === 'upload' ? 'proxy-upload' : 'proxy-download';
|
||||
const proxyBytes = direction === 'upload' ? ['100', '10'] : ['200', '20'];
|
||||
return {
|
||||
status: 0,
|
||||
stdout: `[1:${bytes}] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:${direction}" -j RETURN\n`,
|
||||
stdout: `[1:${bytes}] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:${direction}" -j RETURN\n`
|
||||
+ proxyBytes.map((value) => `[1:${value}] -A ${chain}_P -m comment --comment "harbor-traffic:${firstDevice.key}:${proxyDirection}" -j RETURN`).join('\n'),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
@@ -118,6 +123,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
bypassCidrs: ['10.0.0.0/8'],
|
||||
proxyPort: 8080,
|
||||
run,
|
||||
nextGeneration: () => generations.shift(),
|
||||
});
|
||||
@@ -131,6 +137,8 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
||||
interface: 'eth0',
|
||||
uploadBytes: '1200',
|
||||
downloadBytes: '3400',
|
||||
proxyUploadBytes: '110',
|
||||
proxyDownloadBytes: '220',
|
||||
}]);
|
||||
|
||||
const switchCallsBefore = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length;
|
||||
@@ -177,6 +185,48 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
||||
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
|
||||
});
|
||||
|
||||
test('a 512-device refresh keeps a fixed subprocess count and a cached snapshot', async () => {
|
||||
const observations = Array.from({ length: 512 }, (_, index) => observation(
|
||||
`10.${Math.floor(index / 254)}.${Math.floor((index % 254) / 254)}.${(index % 254) + 1}`,
|
||||
`02:00:${Math.floor(index / 256).toString(16).padStart(2, '0')}:${Math.floor(index / 16).toString(16).padStart(2, '0')}:${(index % 16).toString(16).padStart(2, '0')}:01`,
|
||||
));
|
||||
const calls = [];
|
||||
let releaseRestore;
|
||||
const run = (command, args, options) => {
|
||||
calls.push([command, args, options]);
|
||||
if (command === 'iptables-restore') {
|
||||
return new Promise((resolve) => {
|
||||
releaseRestore = () => resolve({ status: 0, stdout: '', stderr: '' });
|
||||
});
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
const generations = ['epoch', 'rules-a'];
|
||||
const service = createDeviceTrafficService({
|
||||
observe: () => ({ observedAt: '2026-08-07T12:00:00.000Z', observations, error: null }),
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
bypassCidrs: [],
|
||||
proxyPort: 8080,
|
||||
run,
|
||||
nextGeneration: () => generations.shift(),
|
||||
});
|
||||
|
||||
const pending = service.refresh();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(service.snapshot().observedAt, null);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], 'iptables-restore');
|
||||
assert.match(calls[0][2].input, /proxy-upload/);
|
||||
releaseRestore();
|
||||
const snapshot = await pending;
|
||||
assert.equal(snapshot.devices.length, 512);
|
||||
assert.deepEqual(
|
||||
calls.map(([command]) => command),
|
||||
['iptables-restore', 'iptables', 'iptables', 'iptables-save', 'iptables-save'],
|
||||
);
|
||||
});
|
||||
|
||||
test('traffic service finalizes a detached slot once and keeps epoch totals monotonic', async () => {
|
||||
const firstObservation = observation('192.168.50.7', '00:11:22:33:44:55');
|
||||
const secondObservation = observation('192.168.50.8', '00:11:22:33:44:66');
|
||||
@@ -188,8 +238,8 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
|
||||
error: null,
|
||||
};
|
||||
const values = {
|
||||
A: { upload: '100', download: '200' },
|
||||
B: { upload: '5', download: '7' },
|
||||
A: { upload: '100', download: '200', proxyUpload: '30', proxyDownload: '40' },
|
||||
B: { upload: '5', download: '7', proxyUpload: '2', proxyDownload: '3' },
|
||||
};
|
||||
const keys = { A: firstDevice.key, B: secondDevice.key };
|
||||
let failNextCounterRead = false;
|
||||
@@ -200,12 +250,15 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
|
||||
return { status: null, stdout: '', stderr: '', error: new Error('retired slot read failed') };
|
||||
}
|
||||
const direction = args.includes('raw') ? 'upload' : 'download';
|
||||
const proxyDirection = direction === 'upload' ? 'proxyUpload' : 'proxyDownload';
|
||||
const proxyKind = direction === 'upload' ? 'proxy-upload' : 'proxy-download';
|
||||
const tableChain = args.includes('raw') ? uploadChain : downloadChain;
|
||||
return {
|
||||
status: 0,
|
||||
stdout: ['A', 'B'].map((slot) => (
|
||||
`[1:${values[slot][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`
|
||||
)).join('\n'),
|
||||
stdout: ['A', 'B'].flatMap((slot) => [
|
||||
`[1:${values[slot][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`,
|
||||
`[1:${values[slot][proxyDirection]}] -A ${tableChain}_${slot}_P -m comment --comment "harbor-traffic:${keys[slot]}:${proxyKind}" -j RETURN`,
|
||||
]).join('\n'),
|
||||
stderr: '',
|
||||
};
|
||||
};
|
||||
@@ -215,6 +268,7 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
bypassCidrs: [],
|
||||
proxyPort: 8080,
|
||||
run,
|
||||
nextGeneration: () => generations.shift(),
|
||||
});
|
||||
@@ -222,15 +276,17 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
|
||||
const first = await service.refresh();
|
||||
assert.equal(first.epoch, 'epoch-1');
|
||||
assert.equal(first.generation, 'rules-a');
|
||||
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
|
||||
})), [{
|
||||
mac: firstObservation.mac,
|
||||
uploadBytes: '100',
|
||||
downloadBytes: '200',
|
||||
proxyUploadBytes: '30',
|
||||
proxyDownloadBytes: '40',
|
||||
}]);
|
||||
|
||||
values.A = { upload: '130', download: '240' };
|
||||
values.A = { upload: '130', download: '240', proxyUpload: '35', proxyDownload: '48' };
|
||||
observed = {
|
||||
observedAt: '2026-08-07T12:01:00.000Z',
|
||||
observations: [secondObservation],
|
||||
@@ -241,29 +297,29 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
|
||||
assert.equal(pending.epoch, 'epoch-1');
|
||||
assert.equal(pending.generation, 'rules-b');
|
||||
assert.match(pending.source.error, /retired slot read failed/);
|
||||
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
|
||||
})), [
|
||||
{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
|
||||
{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200', proxyUploadBytes: '30', proxyDownloadBytes: '40' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3' },
|
||||
]);
|
||||
|
||||
const finalized = await service.refresh();
|
||||
assert.equal(finalized.generation, 'rules-b');
|
||||
assert.equal(finalized.source.error, null);
|
||||
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
|
||||
})), [
|
||||
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
|
||||
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3' },
|
||||
]);
|
||||
|
||||
values.B = { upload: '15', download: '17' };
|
||||
values.B = { upload: '15', download: '17', proxyUpload: '4', proxyDownload: '6' };
|
||||
const polled = await service.refresh();
|
||||
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes,
|
||||
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
|
||||
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
|
||||
})), [
|
||||
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17' },
|
||||
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48' },
|
||||
{ mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17', proxyUploadBytes: '4', proxyDownloadBytes: '6' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ test('gateway keeps direct forwarding active while TProxy interception is switch
|
||||
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
||||
assert.match(entrypoint, /-t raw -I PREROUTING 1 -j "\$TRAFFIC_UPLOAD_CHAIN"/);
|
||||
assert.match(entrypoint, /-t mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_CHAIN"/);
|
||||
assert.match(entrypoint, /-t raw -N "\$\{TRAFFIC_UPLOAD_CHAIN\}_\$\{slot\}_P"/);
|
||||
assert.match(entrypoint, /-t mangle -N "\$\{TRAFFIC_DOWNLOAD_CHAIN\}_\$\{slot\}_P"/);
|
||||
assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -j "\$DEVICE_POLICY_CHAIN"/);
|
||||
assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p tcp -j TPROXY/);
|
||||
assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p udp -j TPROXY/);
|
||||
|
||||
@@ -40,7 +40,15 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||||
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
||||
assert.match(panel, /Трафик временно не обновляется/);
|
||||
assert.match(panel, /Получено \$\{download\}, отдано \$\{upload\}/);
|
||||
assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/);
|
||||
assert.match(panel, /Gateway: получено \$\{download\}, отдано \$\{upload\}/);
|
||||
assert.match(panel, /Прокси: получено \$\{proxyDownload\}, отдано \$\{proxyUpload\}/);
|
||||
assert.match(panel, /Gateway \{gatewayTraffic\}/);
|
||||
assert.match(panel, /Прокси \{proxyTraffic\}/);
|
||||
assert.match(panel, /\(gatewayTotal > 0n \|\| proxyTotal > 0n\)/);
|
||||
assert.match(panel, /gatewayTotal > 0n && <span>Gateway/);
|
||||
assert.match(panel, /proxyTotal > 0n && <span className="is-proxy">Прокси/);
|
||||
assert.match(panel, /source\?\.traffic\?\.proxy\?\.error/);
|
||||
assert.match(panel, /client-device-traffic-slot[\s\S]*client-device-pin-wrap/);
|
||||
assert.doesNotMatch(panel, /client-device-details/);
|
||||
assert.match(panel, /api\.devices\.setPolicy\(device\.id, mode, snapshot\.revision\)/);
|
||||
@@ -85,10 +93,10 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
|
||||
assert.equal(formatByteString('invalid'), '0 Б');
|
||||
|
||||
const devices = [
|
||||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0' },
|
||||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
|
||||
{ id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' },
|
||||
{ id: 'c', uploadBytes: '10', downloadBytes: '10' },
|
||||
{ id: 'd', uploadBytes: '15', downloadBytes: '5' },
|
||||
{ id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' },
|
||||
{ id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' },
|
||||
];
|
||||
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']);
|
||||
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']);
|
||||
|
||||
Reference in New Issue
Block a user