Track proxy traffic separately in device inventory
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 17:08:24 +03:00
parent 9f31eaf396
commit 53e6cf2146
13 changed files with 714 additions and 146 deletions
+104 -48
View File
@@ -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' },
]);
});