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
+2 -2
View File
@@ -76,11 +76,11 @@ http://АДРЕС-GATEWAY:3456
### Устройства Gateway ### Устройства Gateway
После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённые значения полученного/отданного интернет-трафика. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; название, закрепление и накопленные traffic totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список от большего объёма к меньшему или наоборот. После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённый интернет-трафик. В строке отдельно отмечаются ненулевые источники `Gateway` и `Прокси`; одно устройство может использовать оба, а подробное получено/отдано доступно при наведении или фокусе. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; название, закрепление и накопленные totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список по сумме обоих источников от большего объёма к меньшему или наоборот.
У закреплённого и однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую`. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут; перед откреплением устройство нужно вернуть в `VPN`. У закреплённого и однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую`. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут; перед откреплением устройство нужно вернуть в `VPN`.
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. Локальные, приватные и multicast-пакеты в traffic totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
## Установка Harbor Connect на macOS ## Установка Harbor Connect на macOS
+6
View File
@@ -86,8 +86,12 @@ cleanup_device_traffic() {
ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
for slot in A B; do for slot in A B; do
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
done done
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
@@ -101,7 +105,9 @@ setup_device_traffic() {
ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1 ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
for slot in A B; do for slot in A B; do
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}" || return 1 ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}" || return 1
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || return 1
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1 ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1
done done
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1 ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1
ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1 ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
+1
View File
@@ -20,6 +20,7 @@ const traffic = createDeviceTrafficService({
uploadChain: settings.trafficUploadChain, uploadChain: settings.trafficUploadChain,
downloadChain: settings.trafficDownloadChain, downloadChain: settings.trafficDownloadChain,
bypassCidrs: settings.bypassCidrs, bypassCidrs: settings.bypassCidrs,
proxyPort: settings.proxyPort,
}); });
const devicePolicy = createDevicePolicyService({ const devicePolicy = createDevicePolicyService({
chain: settings.devicePolicyChain, chain: settings.devicePolicyChain,
+180 -1
View File
@@ -15,6 +15,7 @@ const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i; const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
const POLICY_MODES = new Set(['vpn', 'direct']); const POLICY_MODES = new Set(['vpn', 'direct']);
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']); const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
const DEFAULT_DEVICE_POLICY = Object.freeze({ const DEFAULT_DEVICE_POLICY = Object.freeze({
desired: 'vpn', desired: 'vpn',
@@ -36,6 +37,15 @@ const DEFAULT_POLICY_STATE = {
byMac: {}, byMac: {},
}; };
const DEFAULT_PROXY_TRAFFIC = {
schemaVersion: 1,
lastObservedAt: null,
lastError: null,
baselinesByMac: {},
totalsByMac: {},
rebaselineMacs: [],
};
const DEFAULT_STATE = { const DEFAULT_STATE = {
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: 0, revision: 0,
@@ -50,6 +60,7 @@ const DEFAULT_STATE = {
baselinesByMac: {}, baselinesByMac: {},
totalsByMac: {}, totalsByMac: {},
rebaselineMacs: [], rebaselineMacs: [],
proxy: DEFAULT_PROXY_TRAFFIC,
}, },
devices: [], devices: [],
}; };
@@ -65,6 +76,68 @@ const parseStoredCounter = (value) => {
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null; return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
}; };
function normalizeProxyTraffic(value, devices) {
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`);
}
const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
let recovered = value !== undefined && (
proxy !== value || proxy.schemaVersion !== 1
|| !proxy.baselinesByMac || typeof proxy.baselinesByMac !== 'object' || Array.isArray(proxy.baselinesByMac)
|| !proxy.totalsByMac || typeof proxy.totalsByMac !== 'object' || Array.isArray(proxy.totalsByMac)
);
if (recovered) {
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
}
const baselinesByMac = {};
for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) {
const mac = normalizeMac(rawMac);
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
const downloadBytes = parseStoredCounter(baseline?.downloadBytes);
if (!MAC_PATTERN.test(mac) || typeof baseline?.epoch !== 'string' || !baseline.epoch
|| uploadBytes == null || downloadBytes == null) {
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
recovered = true;
continue;
}
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
}
const totalsByMac = {};
for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) {
const mac = normalizeMac(rawMac);
const uploadBytes = parseStoredCounter(total?.uploadBytes);
const downloadBytes = parseStoredCounter(total?.downloadBytes);
if (!MAC_PATTERN.test(mac) || uploadBytes == null || downloadBytes == null) {
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
recovered = true;
continue;
}
totalsByMac[mac] = {
uploadBytes,
downloadBytes,
observedAt: typeof total?.observedAt === 'string' ? total.observedAt : null,
};
}
for (const mac of new Set([...Object.keys(baselinesByMac), ...Object.keys(totalsByMac)])) {
if (!Object.hasOwn(baselinesByMac, mac) || !Object.hasOwn(totalsByMac, mac)) {
rebaselineMacs.add(mac);
recovered = true;
}
}
return {
schemaVersion: 1,
lastObservedAt: typeof proxy.lastObservedAt === 'string' ? proxy.lastObservedAt : null,
lastError: recovered
? PROXY_RECOVERY_ERROR
: typeof proxy.lastError === 'string' ? proxy.lastError : null,
baselinesByMac,
totalsByMac,
rebaselineMacs: [...rebaselineMacs],
};
}
function normalizePolicyState(value) { function normalizePolicyState(value) {
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const byMac = {}; const byMac = {};
@@ -142,6 +215,7 @@ export function migrateDeviceInventoryState(value) {
? state.traffic ? state.traffic
: {}; : {};
const devices = Array.isArray(state.devices) ? state.devices : []; const devices = Array.isArray(state.devices) ? state.devices : [];
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
let recoveredTraffic = version >= 2 && ( let recoveredTraffic = version >= 2 && (
@@ -202,6 +276,7 @@ export function migrateDeviceInventoryState(value) {
baselinesByMac, baselinesByMac,
totalsByMac, totalsByMac,
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)), rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
proxy: proxyTraffic,
}, },
devices, devices,
}; };
@@ -276,6 +351,7 @@ export function createDeviceInventoryService({
const rank = { online: 0, recent: 1, offline: 2 }; const rank = { online: 0, recent: 1, offline: 2 };
const devices = state.devices.map((device) => { const devices = state.devices.map((device) => {
const traffic = state.traffic.totalsByMac[device.mac]; const traffic = state.traffic.totalsByMac[device.mac];
const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac];
const policy = policyFor(state, device.mac); const policy = policyFor(state, device.mac);
return { return {
...device, ...device,
@@ -283,6 +359,9 @@ export function createDeviceInventoryService({
uploadBytes: traffic?.uploadBytes || '0', uploadBytes: traffic?.uploadBytes || '0',
downloadBytes: traffic?.downloadBytes || '0', downloadBytes: traffic?.downloadBytes || '0',
trafficObservedAt: traffic?.observedAt || null, trafficObservedAt: traffic?.observedAt || null,
proxyUploadBytes: proxyTraffic?.uploadBytes || '0',
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
desiredPolicy: policy.desired, desiredPolicy: policy.desired,
appliedPolicy: policy.applied, appliedPolicy: policy.applied,
policyStatus: policy.status, policyStatus: policy.status,
@@ -303,6 +382,10 @@ export function createDeviceInventoryService({
traffic: { traffic: {
lastObservedAt: state.traffic.lastObservedAt, lastObservedAt: state.traffic.lastObservedAt,
error: state.traffic.lastError, error: state.traffic.lastError,
proxy: {
lastObservedAt: state.traffic.proxy.lastObservedAt,
error: state.traffic.proxy.lastError,
},
}, },
policy: { policy: {
lastAppliedAt: state.policy.lastAppliedAt, lastAppliedAt: state.policy.lastAppliedAt,
@@ -489,8 +572,13 @@ export function createDeviceInventoryService({
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) { if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
throw new Error('Dataplane не вернул traffic epoch'); throw new Error('Dataplane не вернул traffic epoch');
} }
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : [];
const processByMac = new Map(); const processByMac = new Map();
for (const row of Array.isArray(trafficResult.devices) ? trafficResult.devices : []) { const proxyByMac = new Map();
let proxyRows = 0;
let legacyRows = 0;
let proxySampleError = null;
for (const row of rows) {
const mac = normalizeMac(row?.mac); const mac = normalizeMac(row?.mac);
const upload = String(row?.uploadBytes ?? ''); const upload = String(row?.uploadBytes ?? '');
const download = String(row?.downloadBytes ?? ''); const download = String(row?.downloadBytes ?? '');
@@ -502,7 +590,30 @@ export function createDeviceInventoryService({
upload: previous.upload + BigInt(upload), upload: previous.upload + BigInt(upload),
download: previous.download + BigInt(download), download: previous.download + BigInt(download),
}); });
const hasProxyUpload = Object.hasOwn(row || {}, 'proxyUploadBytes');
const hasProxyDownload = Object.hasOwn(row || {}, 'proxyDownloadBytes');
if (!hasProxyUpload && !hasProxyDownload) {
legacyRows += 1;
continue;
}
if (!hasProxyUpload || !hasProxyDownload) {
proxySampleError = 'Dataplane вернул неполный proxy traffic counter';
continue;
}
const proxyUpload = String(row.proxyUploadBytes);
const proxyDownload = String(row.proxyDownloadBytes);
if (!COUNTER_PATTERN.test(proxyUpload) || !COUNTER_PATTERN.test(proxyDownload)) {
proxySampleError = 'Dataplane вернул невалидный proxy traffic counter';
continue;
}
proxyRows += 1;
const previousProxy = proxyByMac.get(mac) || { upload: 0n, download: 0n };
proxyByMac.set(mac, {
upload: previousProxy.upload + BigInt(proxyUpload),
download: previousProxy.download + BigInt(proxyDownload),
});
} }
if (proxyRows && legacyRows) proxySampleError = 'Dataplane смешал старый и новый proxy traffic contract';
const knownMacs = new Set(devices.map((device) => device.mac)); const knownMacs = new Set(devices.map((device) => device.mac));
const baselinesByMac = { ...traffic.baselinesByMac }; const baselinesByMac = { ...traffic.baselinesByMac };
const totalsByMac = { ...traffic.totalsByMac }; const totalsByMac = { ...traffic.totalsByMac };
@@ -546,6 +657,73 @@ export function createDeviceInventoryService({
rebaselineMacs.delete(mac); rebaselineMacs.delete(mac);
} }
} }
const proxyBaselines = { ...traffic.proxy.baselinesByMac };
const proxyTotals = { ...traffic.proxy.totalsByMac };
const proxyRebaseline = new Set(traffic.proxy.rebaselineMacs);
for (const mac of new Set([
...Object.keys(proxyTotals),
...Object.keys(proxyBaselines),
...proxyRebaseline,
])) {
if (knownMacs.has(mac)) continue;
delete proxyTotals[mac];
delete proxyBaselines[mac];
proxyRebaseline.delete(mac);
}
let proxy = {
...traffic.proxy,
lastError: !proxyRebaseline.size && traffic.proxy.lastError === PROXY_RECOVERY_ERROR
? null
: traffic.proxy.lastError,
baselinesByMac: proxyBaselines,
totalsByMac: proxyTotals,
rebaselineMacs: [...proxyRebaseline],
};
if (proxySampleError) {
proxy = { ...proxy, lastError: proxySampleError };
} else if (proxyRows) {
try {
const nextProxyBaselines = { ...proxyBaselines };
const nextProxyTotals = { ...proxyTotals };
const nextProxyRebaseline = new Set(proxyRebaseline);
for (const [mac, processTotal] of proxyByMac) {
if (!knownMacs.has(mac)) continue;
const baseline = nextProxyBaselines[mac];
const recovering = nextProxyRebaseline.has(mac);
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
throw new Error('Dataplane proxy traffic counter уменьшился внутри одного epoch');
}
const total = nextProxyTotals[mac] || { uploadBytes: '0', downloadBytes: '0' };
nextProxyTotals[mac] = {
uploadBytes: (BigInt(total.uploadBytes)
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
downloadBytes: (BigInt(total.downloadBytes)
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
observedAt: trafficResult.observedAt || proxy.lastObservedAt,
};
nextProxyBaselines[mac] = {
epoch: trafficResult.epoch,
uploadBytes: processTotal.upload.toString(),
downloadBytes: processTotal.download.toString(),
};
nextProxyRebaseline.delete(mac);
}
proxy = {
...proxy,
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
lastError: trafficResult.source?.error
|| (nextProxyRebaseline.size ? proxy.lastError : null),
baselinesByMac: nextProxyBaselines,
totalsByMac: nextProxyTotals,
rebaselineMacs: [...nextProxyRebaseline],
};
} catch (error) {
proxy = { ...proxy, lastError: error.message || String(error) };
}
}
traffic = { traffic = {
...traffic, ...traffic,
epoch: trafficResult.epoch, epoch: trafficResult.epoch,
@@ -556,6 +734,7 @@ export function createDeviceInventoryService({
baselinesByMac, baselinesByMac,
totalsByMac, totalsByMac,
rebaselineMacs: [...rebaselineMacs], rebaselineMacs: [...rebaselineMacs],
proxy,
}; };
} catch (error) { } catch (error) {
traffic = { ...traffic, lastError: error.message || String(error) }; traffic = { ...traffic, lastError: error.message || String(error) };
+149 -78
View File
@@ -1,12 +1,20 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import net from 'node:net'; import net from 'node:net';
import { spawnSync } from 'node:child_process'; import { spawn } from 'node:child_process';
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' }; const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i; const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
const INTERFACE_PATTERN = /^[a-z0-9_.:+-]{1,15}$/i; const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
const COUNTERS = [
['upload', 'upload', 'uploadBytes'],
['download', 'download', 'downloadBytes'],
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
];
const childChain = (chain, slot) => `${chain}_${slot}`; const childChain = (chain, slot) => `${chain}_${slot}`;
const proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`;
const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto
.createHash('sha256') .createHash('sha256')
.update(`${ip}|${mac}|${deviceInterface}`) .update(`${ip}|${mac}|${deviceInterface}`)
@@ -19,6 +27,55 @@ function commandError(command, result) {
).trim()); ).trim());
} }
function runCommand(command, args, options = COMMAND_OPTIONS) {
return new Promise((resolve) => {
let child;
try {
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
} catch (error) {
resolve({ status: null, stdout: '', stderr: '', error });
return;
}
const stdout = [];
const stderr = [];
let settled = false;
let timedOut = false;
let timer;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
stdout: Buffer.concat(stdout).toString(options.encoding || 'utf8'),
stderr: Buffer.concat(stderr).toString(options.encoding || 'utf8'),
...result,
});
};
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.on('error', (error) => finish({ status: null, error }));
child.on('close', (status) => finish({
status,
error: timedOut ? new Error(`${command} превысил ${options.timeout} мс`) : null,
}));
timer = setTimeout(() => {
timedOut = true;
child.kill(options.killSignal || 'SIGKILL');
}, options.timeout);
child.stdin.on('error', () => {});
child.stdin.end(options.input == null ? undefined : options.input);
});
}
function isIpv4Cidr(value) {
const [address, prefix, extra] = String(value).split('/');
const size = Number(prefix);
return extra === undefined && net.isIPv4(address)
&& Number.isInteger(size) && size >= 0 && size <= 32;
}
const zeroCounters = () => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
export function selectTrafficDevices(observations) { export function selectTrafficDevices(observations) {
const candidates = new Map(); const candidates = new Map();
const ipsByMac = new Map(); const ipsByMac = new Map();
@@ -49,57 +106,72 @@ export function selectTrafficDevices(observations) {
)); ));
} }
export function buildTrafficRuleCommands({ export function buildTrafficRestore({
devices, devices,
bypassCidrs, bypassCidrs,
uploadChain, uploadChain,
downloadChain, downloadChain,
slot, slot,
proxyPort,
}) { }) {
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|| proxyPort < 1 || proxyPort > 65_535
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))) {
throw new Error('Некорректная конфигурация traffic accounting');
}
const uploadChild = childChain(uploadChain, slot); const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot); const downloadChild = childChain(downloadChain, slot);
const commands = [ const proxyUploadChild = proxyChildChain(uploadChain, slot);
['iptables', ['-w', '1', '-t', 'raw', '-F', uploadChild]], const proxyDownloadChild = proxyChildChain(downloadChain, slot);
['iptables', ['-w', '1', '-t', 'mangle', '-F', downloadChild]], const raw = [
['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-i', 'br-+', '-j', 'RETURN']], '*raw',
`-F ${uploadChild}`,
`-F ${proxyUploadChild}`,
`-A ${uploadChild} -i br-+ -j RETURN`,
`-A ${uploadChild} -p tcp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j ${proxyUploadChild}`,
`-A ${uploadChild} -p tcp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j RETURN`,
`-A ${uploadChild} -p udp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j ${proxyUploadChild}`,
`-A ${uploadChild} -p udp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j RETURN`,
];
const mangle = [
'*mangle',
`-F ${downloadChild}`,
`-F ${proxyDownloadChild}`,
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
]; ];
for (const device of devices) {
for (const protocol of ['tcp', 'udp']) {
raw.push(`-A ${proxyUploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -p ${protocol} -m comment --comment harbor-traffic:${device.key}:proxy-upload -j RETURN`);
mangle.push(`-A ${proxyDownloadChild} -o ${device.interface} -d ${device.ip} -p ${protocol} -m comment --comment harbor-traffic:${device.key}:proxy-download -j RETURN`);
}
}
for (const cidr of bypassCidrs) { for (const cidr of bypassCidrs) {
commands.push( raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-d', cidr, '-j', 'RETURN']], mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`);
['iptables', ['-w', '1', '-t', 'mangle', '-A', downloadChild, '-s', cidr, '-j', 'RETURN']],
);
} }
for (const device of devices) { for (const device of devices) {
commands.push( raw.push(`-A ${uploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-traffic:${device.key}:upload -j RETURN`);
['iptables', [ mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m comment --comment harbor-traffic:${device.key}:download -j RETURN`);
'-w', '1', '-t', 'raw', '-A', uploadChild,
'-i', device.interface, '-s', device.ip,
'-m', 'mac', '--mac-source', device.mac,
'-m', 'comment', '--comment', `harbor-traffic:${device.key}:upload`,
'-j', 'RETURN',
]],
['iptables', [
'-w', '1', '-t', 'mangle', '-A', downloadChild,
'-o', device.interface, '-d', device.ip,
'-m', 'comment', '--comment', `harbor-traffic:${device.key}:download`,
'-j', 'RETURN',
]],
);
} }
return commands; return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
} }
export function parseTrafficCounters(text, chain) { export function parseTrafficCounters(text, chain) {
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linePattern = new RegExp( const linePattern = new RegExp(
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download)"?`, `^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
); );
const counters = new Map(); const counters = new Map();
for (const line of String(text || '').split(/\r?\n/)) { for (const line of String(text || '').split(/\r?\n/)) {
const match = line.match(linePattern); const match = line.match(linePattern);
if (!match) continue; if (!match) continue;
counters.set(`${match[3]}:${match[4]}`, match[2]); const key = `${match[3]}:${match[4]}`;
counters.set(key, (BigInt(counters.get(key) || '0') + BigInt(match[2])).toString());
} }
return counters; return counters;
} }
@@ -109,7 +181,8 @@ export function createDeviceTrafficService({
uploadChain, uploadChain,
downloadChain, downloadChain,
bypassCidrs, bypassCidrs,
run = spawnSync, proxyPort,
run = runCommand,
nextGeneration = () => crypto.randomUUID(), nextGeneration = () => crypto.randomUUID(),
}) { }) {
const epoch = nextGeneration(); const epoch = nextGeneration();
@@ -129,23 +202,25 @@ export function createDeviceTrafficService({
devices: [], devices: [],
}; };
function execute(command, args) { async function execute(command, args, options = COMMAND_OPTIONS) {
const result = run(command, args, COMMAND_OPTIONS); const result = await run(command, args, options);
if (result.error || result.status !== 0) throw commandError(command, result); if (result.error || result.status !== 0) throw commandError(command, result);
return String(result.stdout || ''); return String(result.stdout || '');
} }
function prepare(slot, devices) { async function prepare(slot, devices) {
for (const [command, args] of buildTrafficRuleCommands({ const input = buildTrafficRestore({
devices, devices,
bypassCidrs, bypassCidrs,
uploadChain, uploadChain,
downloadChain, downloadChain,
slot, slot,
})) execute(command, args); proxyPort,
});
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
} }
function switchTo(slot) { async function switchTo(slot) {
const uploadChild = childChain(uploadChain, slot); const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot); const downloadChild = childChain(downloadChain, slot);
const replace = activeSlot ? '-R' : '-A'; const replace = activeSlot ? '-R' : '-A';
@@ -156,32 +231,35 @@ export function createDeviceTrafficService({
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild] ? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild]
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild]; : ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild];
execute('iptables', uploadArgs); await execute('iptables', uploadArgs);
try { try {
execute('iptables', downloadArgs); await execute('iptables', downloadArgs);
} catch (error) { } catch (error) {
const rollbackArgs = activeSlot const rollbackArgs = activeSlot
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)] ? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
: ['-w', '1', '-t', 'raw', '-F', uploadChain]; : ['-w', '1', '-t', 'raw', '-F', uploadChain];
execute('iptables', rollbackArgs); await execute('iptables', rollbackArgs);
throw error; throw error;
} }
} }
function readCounters(devices, slot) { async function readCounters(devices, slot) {
if (!slot) return new Map(); if (!slot) return new Map();
const upload = parseTrafficCounters( const [raw, mangle] = await Promise.all([
execute('iptables-save', ['-c', '-t', 'raw']), execute('iptables-save', ['-c', '-t', 'raw']),
childChain(uploadChain, slot),
);
const download = parseTrafficCounters(
execute('iptables-save', ['-c', '-t', 'mangle']), execute('iptables-save', ['-c', '-t', 'mangle']),
childChain(downloadChain, slot), ]);
); const parsed = {
upload: parseTrafficCounters(raw, childChain(uploadChain, slot)),
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
};
const counters = new Map(); const counters = new Map();
for (const { key } of devices) { for (const { key } of devices) {
counters.set(`${key}:upload`, upload.get(`${key}:upload`) || '0'); for (const [kind, field] of COUNTERS) {
counters.set(`${key}:download`, download.get(`${key}:download`) || '0'); counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
}
} }
return counters; return counters;
} }
@@ -194,15 +272,14 @@ export function createDeviceTrafficService({
for (const device of devices) devicesByKey.set(device.key, device); for (const device of devices) devicesByKey.set(device.key, device);
} }
function finalizeRetired() { async function finalizeRetired() {
if (!pendingRetired) return false; if (!pendingRetired) return false;
const counters = readCounters(pendingRetired.devices, pendingRetired.slot); const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
for (const { key } of pendingRetired.devices) { for (const { key } of pendingRetired.devices) {
const previous = finalized.get(key) || { upload: 0n, download: 0n }; const previous = finalized.get(key) || zeroCounters();
finalized.set(key, { const next = { ...previous };
upload: previous.upload + counter(counters, key, 'upload'), for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
download: previous.download + counter(counters, key, 'download'), finalized.set(key, next);
});
} }
pendingRetired = null; pendingRetired = null;
return true; return true;
@@ -212,27 +289,21 @@ export function createDeviceTrafficService({
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device])); const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
const totalsByMac = new Map(); const totalsByMac = new Map();
for (const [key, remembered] of devicesByKey) { for (const [key, remembered] of devicesByKey) {
const base = finalized.get(key) || { upload: 0n, download: 0n }; const base = finalized.get(key) || zeroCounters();
const pending = pendingRetired?.counters || new Map(); const pending = pendingRetired?.counters || new Map();
const upload = base.upload const previous = totalsByMac.get(remembered.mac) || zeroCounters();
+ counter(pending, key, 'upload') const total = { ...(activeByMac.get(remembered.mac) || remembered) };
+ counter(activeCounters, key, 'upload'); for (const [kind, field] of COUNTERS) {
const download = base.download total[field] = previous[field] + base[field]
+ counter(pending, key, 'download') + counter(pending, key, kind) + counter(activeCounters, key, kind);
+ counter(activeCounters, key, 'download'); }
const previous = totalsByMac.get(remembered.mac) || { upload: 0n, download: 0n }; totalsByMac.set(remembered.mac, total);
totalsByMac.set(remembered.mac, {
...(activeByMac.get(remembered.mac) || remembered),
upload: previous.upload + upload,
download: previous.download + download,
});
} }
return [...totalsByMac.values()] return [...totalsByMac.values()]
.map(({ key: _key, upload, download, ...device }) => ({ .map((total) => Object.fromEntries([
...device, ...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)),
uploadBytes: upload.toString(), ...COUNTERS.map(([, field, output]) => [output, total[field].toString()]),
downloadBytes: download.toString(), ]))
}))
.sort((left, right) => left.mac.localeCompare(right.mac)); .sort((left, right) => left.mac.localeCompare(right.mac));
} }
@@ -253,7 +324,7 @@ export function createDeviceTrafficService({
if (pendingRetired) { if (pendingRetired) {
try { try {
countersRead = finalizeRetired() || countersRead; countersRead = await finalizeRetired() || countersRead;
} catch (error) { } catch (error) {
sourceError = sourceError || error.message || String(error); sourceError = sourceError || error.message || String(error);
} }
@@ -262,8 +333,8 @@ export function createDeviceTrafficService({
if (!pendingRetired && !sourceError && nextSignature !== activeSignature) { if (!pendingRetired && !sourceError && nextSignature !== activeSignature) {
const nextSlot = activeSlot === 'A' ? 'B' : 'A'; const nextSlot = activeSlot === 'A' ? 'B' : 'A';
try { try {
prepare(nextSlot, nextDevices); await prepare(nextSlot, nextDevices);
switchTo(nextSlot); await switchTo(nextSlot);
const retired = activeSlot ? { const retired = activeSlot ? {
slot: activeSlot, slot: activeSlot,
devices: activeDevices, devices: activeDevices,
@@ -276,14 +347,14 @@ export function createDeviceTrafficService({
pendingRetired = retired; pendingRetired = retired;
remember(nextDevices); remember(nextDevices);
current.generation = nextGeneration(); current.generation = nextGeneration();
if (pendingRetired) countersRead = finalizeRetired() || countersRead; if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
} catch (error) { } catch (error) {
sourceError = error.message || String(error); sourceError = error.message || String(error);
} }
} }
try { try {
activeCounters = readCounters(activeDevices, activeSlot); activeCounters = await readCounters(activeDevices, activeSlot);
countersRead = true; countersRead = true;
} catch (error) { } catch (error) {
sourceError = sourceError || error.message || String(error); sourceError = sourceError || error.message || String(error);
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.12.1', macClient: '0.12.2',
gatewayClient: '0.12.1', gatewayClient: '0.13.0',
gatewayBackend: '0.12.0', gatewayBackend: '0.13.0',
}); });
export function parseVersion(value) { export function parseVersion(value) {
+26 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { import {
byteString,
formatByteString, formatByteString,
formatLastSeen, formatLastSeen,
sortDevicesByTraffic, sortDevicesByTraffic,
@@ -210,6 +211,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<h2 id="client-devices-title">Устройства</h2> <h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro"> <div className="client-instructions-intro">
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p> <p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
<p>Учитывается только трафик, который прошёл через Harbor.</p>
</div> </div>
</header> </header>
@@ -223,6 +225,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
Трафик временно не обновляется. Показаны последние сохранённые значения. Трафик временно не обновляется. Показаны последние сохранённые значения.
</p> </p>
)} )}
{snapshot?.source?.traffic?.proxy?.error && (
<p className="client-devices-source" role="status">
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{snapshot?.source?.policy?.error && ( {snapshot?.source?.policy?.error && (
<p className="client-devices-source" role="status"> <p className="client-devices-source" role="status">
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние. Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
@@ -248,6 +255,16 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const online = device.status === 'online'; const online = device.status === 'online';
const download = formatByteString(device.downloadBytes); const download = formatByteString(device.downloadBytes);
const upload = formatByteString(device.uploadBytes); const upload = formatByteString(device.uploadBytes);
const proxyDownload = formatByteString(device.proxyDownloadBytes);
const proxyUpload = formatByteString(device.proxyUploadBytes);
const gatewayTotal = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxyTotal = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const gatewayTraffic = formatByteString(gatewayTotal.toString());
const proxyTraffic = formatByteString(proxyTotal.toString());
const trafficLabel = [
gatewayTotal > 0n ? `Gateway: получено ${download}, отдано ${upload}` : '',
proxyTotal > 0n ? `Прокси: получено ${proxyDownload}, отдано ${proxyUpload}` : '',
].filter(Boolean).join('. ');
const policyBusy = device.policyStatus === 'applying'; const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed'; const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending'; const policyPending = device.policyStatus === 'pending';
@@ -314,11 +331,16 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</div> </div>
)} )}
<span className="client-device-traffic-slot"> <span className="client-device-traffic-slot">
{device.trafficObservedAt && <span {(gatewayTotal > 0n || proxyTotal > 0n) && <span
className="client-device-traffic" className="client-device-traffic client-tooltip-anchor"
aria-label={`Получено ${download}, отдано ${upload}`} tabIndex="0"
aria-label={trafficLabel}
> >
<span aria-hidden="true"> {download} · {upload}</span> <span className="client-device-traffic-sources" aria-hidden="true">
{gatewayTotal > 0n && <span>Gateway {gatewayTraffic}</span>}
{proxyTotal > 0n && <span className="is-proxy">Прокси {proxyTraffic}</span>}
</span>
<Tooltip>{trafficLabel}</Tooltip>
</span>} </span>}
</span> </span>
<span className="client-device-pin-wrap client-tooltip-anchor"> <span className="client-device-pin-wrap client-tooltip-anchor">
+42
View File
@@ -1071,8 +1071,12 @@ p {
} }
.client-device-traffic { .client-device-traffic {
display: block;
max-width: min(240px, 42vw);
overflow: hidden;
color: var(--client-text); color: var(--client-text);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -1081,6 +1085,44 @@ p {
font-size: 9px; font-size: 9px;
} }
.client-device-traffic-sources {
display: flex;
gap: 5px;
overflow: hidden;
}
.client-device-traffic-sources span {
overflow: hidden;
text-overflow: ellipsis;
}
.client-device-traffic-sources span + span::before {
margin-right: 5px;
color: var(--client-muted);
content: '·';
}
.client-device-traffic-sources .is-proxy {
color: var(--client-accent);
}
.client-device-traffic:focus-visible {
border-radius: 3px;
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
.client-device-traffic.client-tooltip-anchor > .client-tooltip {
right: 0;
left: auto;
transform: translate(0, 2px);
}
.client-device-traffic.client-tooltip-anchor:hover > .client-tooltip,
.client-device-traffic.client-tooltip-anchor:focus-visible > .client-tooltip {
transform: translate(0, 0);
}
.client-device-policy-wrap { .client-device-policy-wrap {
width: 72px; width: 72px;
position: relative; position: relative;
+4 -2
View File
@@ -36,8 +36,10 @@ export function sortDevicesByTraffic(devices, direction = 'desc') {
return (Array.isArray(devices) ? devices : []) return (Array.isArray(devices) ? devices : [])
.map((device, index) => ({ device, index })) .map((device, index) => ({ device, index }))
.sort((left, right) => { .sort((left, right) => {
const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes); const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes)
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes); + byteString(left.device.proxyUploadBytes) + byteString(left.device.proxyDownloadBytes);
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes)
+ byteString(right.device.proxyUploadBytes) + byteString(right.device.proxyDownloadBytes);
if (leftTotal === rightTotal) return left.index - right.index; if (leftTotal === rightTotal) return left.index - right.index;
return (leftTotal < rightTotal ? -1 : 1) * factor; return (leftTotal < rightTotal ? -1 : 1) * factor;
}) })
+183 -4
View File
@@ -136,6 +136,8 @@ test('device traffic totals persist exact deltas across polls and process epochs
interface: 'eth0', interface: 'eth0',
uploadBytes: '9007199254740993', uploadBytes: '9007199254740993',
downloadBytes: '100', downloadBytes: '100',
proxyUploadBytes: '1000',
proxyDownloadBytes: '2000',
}], }],
}; };
let trafficError = null; 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].uploadBytes, '9007199254740993');
assert.equal(snapshot.devices[0].downloadBytes, '100'); assert.equal(snapshot.devices[0].downloadBytes, '100');
assert.equal(snapshot.devices[0].trafficObservedAt, observedAt); 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(); snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993'); 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 = {
...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(); snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995'); assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
assert.equal(snapshot.devices[0].downloadBytes, '150'); assert.equal(snapshot.devices[0].downloadBytes, '150');
assert.equal(snapshot.devices[0].proxyUploadBytes, '1010');
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2050');
service = createService(); service = createService();
snapshot = await service.refresh(); snapshot = await service.refresh();
@@ -176,11 +192,19 @@ test('device traffic totals persist exact deltas across polls and process epochs
...traffic, ...traffic,
epoch: 'epoch-b', epoch: 'epoch-b',
generation: 'rules-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(); snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005'); assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.equal(snapshot.devices[0].downloadBytes, '170'); assert.equal(snapshot.devices[0].downloadBytes, '170');
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
traffic = { traffic = {
...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.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.match(snapshot.source.traffic.error, /уменьшился/); 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'); trafficError = new Error('traffic unavailable');
snapshot = await service.refresh(); 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.lastObservedAt, observedAt);
assert.equal(snapshot.source.traffic.error, 'traffic unavailable'); 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) => { 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-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true })); 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).uploadBytes, '20');
assert.equal(byMac.get(missingTotalMac).downloadBytes, '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);
});
+104 -48
View File
@@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { import {
buildTrafficRuleCommands, buildTrafficRestore,
createDeviceTrafficService, createDeviceTrafficService,
parseTrafficCounters, parseTrafficCounters,
selectTrafficDevices, 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.9', '00:11:22:33:44:66'),
observation('192.168.50.10', '00:11:22:33:44:77', 'bad interface'), 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.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'), 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}$/); 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 [device] = selectTrafficDevices([observation('192.168.50.7')]);
const commands = buildTrafficRuleCommands({ const restore = buildTrafficRestore({
devices: [device], devices: [device],
bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'], bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'],
uploadChain, uploadChain,
downloadChain, downloadChain,
slot: 'A', slot: 'A',
proxyPort: 8080,
}); });
const args = commands.map(([, commandArgs]) => commandArgs); 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';
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')); const proxyUploadReturn = '-A VPN_PROXY_TRAFFIC_UP_A -p tcp --dport 8080 -m addrtype --dst-type LOCAL -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')); const proxyDownloadJump = '-A VPN_PROXY_TRAFFIC_DOWN_A -p tcp --sport 8080 -m addrtype --src-type LOCAL -j VPN_PROXY_TRAFFIC_DOWN_A_P';
assert.ok(args.some((value) => ( const proxyDownloadReturn = '-A VPN_PROXY_TRAFFIC_DOWN_A -p tcp --sport 8080 -m addrtype --src-type LOCAL -j RETURN';
value.includes('--mac-source') && value.includes('00:11:22:33:44:55') assert.ok(lines.includes(proxyUploadJump));
&& value.includes('-i') && value.includes('eth0') && value.includes('-s') assert.ok(lines.includes(proxyUploadReturn));
))); assert.ok(lines.indexOf(proxyUploadJump) < lines.indexOf(proxyUploadReturn));
assert.ok(args.some((value) => ( assert.ok(lines.includes(proxyDownloadJump));
value.some((part) => part.startsWith('harbor-traffic:')) && value.includes('-o') assert.ok(lines.includes(proxyDownloadReturn));
&& value.includes('eth0') && value.includes('-d') && value.includes('192.168.50.7') 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.ok(args.filter((value) => value.includes('-j')).every((value) => value[value.indexOf('-j') + 1] === 'RETURN')); assert.match(restore, /-A VPN_PROXY_TRAFFIC_DOWN_A_P .*192\.168\.50\.7 .*harbor-traffic:[a-f0-9]{16}:proxy-download/);
const uploadDeviceIndex = args.findIndex((value) => value.some((part) => part.endsWith(':upload'))); assert.ok(lines.indexOf('-A VPN_PROXY_TRAFFIC_UP_A -d 10.0.0.0/8 -j RETURN')
const downloadDeviceIndex = args.findIndex((value) => value.some((part) => part.endsWith(':download'))); < lines.findIndex((line) => line.endsWith(':upload -j RETURN')));
assert.ok(args.filter((value) => value.includes('-d') && value.includes('10.0.0.0/8')) assert.ok(lines.indexOf('-A VPN_PROXY_TRAFFIC_DOWN_A -s 10.0.0.0/8 -j RETURN')
.every((value) => args.indexOf(value) < uploadDeviceIndex)); < lines.findIndex((line) => line.endsWith(':download -j RETURN')));
assert.ok(args.filter((value) => value.includes('-s') && value.includes('10.0.0.0/8')) assert.doesNotMatch(restore, /TPROXY|DNAT|SNAT|REDIRECT/);
.every((value) => args.indexOf(value) < downloadDeviceIndex));
}); });
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( 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', '[7:9007199254740993] -A VPN_PROXY_TRAFFIC_UP_A_P -p tcp -m comment --comment "harbor-traffic:0123456789abcdef:proxy-upload" -j RETURN\n'
'VPN_PROXY_TRAFFIC_UP_A', + '[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 () => { 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 direction = args.includes('raw') ? 'upload' : 'download';
const chain = direction === 'upload' ? `${uploadChain}_A` : `${downloadChain}_A`; const chain = direction === 'upload' ? `${uploadChain}_A` : `${downloadChain}_A`;
const bytes = direction === 'upload' ? '1200' : '3400'; const bytes = direction === 'upload' ? '1200' : '3400';
const proxyDirection = direction === 'upload' ? 'proxy-upload' : 'proxy-download';
const proxyBytes = direction === 'upload' ? ['100', '10'] : ['200', '20'];
return { return {
status: 0, 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: '', stderr: '',
}; };
} }
@@ -118,6 +123,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
uploadChain, uploadChain,
downloadChain, downloadChain,
bypassCidrs: ['10.0.0.0/8'], bypassCidrs: ['10.0.0.0/8'],
proxyPort: 8080,
run, run,
nextGeneration: () => generations.shift(), nextGeneration: () => generations.shift(),
}); });
@@ -131,6 +137,8 @@ test('traffic service preserves active rules and snapshot when replacement fails
interface: 'eth0', interface: 'eth0',
uploadBytes: '1200', uploadBytes: '1200',
downloadBytes: '3400', downloadBytes: '3400',
proxyUploadBytes: '110',
proxyDownloadBytes: '220',
}]); }]);
const switchCallsBefore = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length; 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)); 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 () => { 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 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'); 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, error: null,
}; };
const values = { const values = {
A: { upload: '100', download: '200' }, A: { upload: '100', download: '200', proxyUpload: '30', proxyDownload: '40' },
B: { upload: '5', download: '7' }, B: { upload: '5', download: '7', proxyUpload: '2', proxyDownload: '3' },
}; };
const keys = { A: firstDevice.key, B: secondDevice.key }; const keys = { A: firstDevice.key, B: secondDevice.key };
let failNextCounterRead = false; 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') }; return { status: null, stdout: '', stderr: '', error: new Error('retired slot read failed') };
} }
const direction = args.includes('raw') ? 'upload' : 'download'; 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; const tableChain = args.includes('raw') ? uploadChain : downloadChain;
return { return {
status: 0, status: 0,
stdout: ['A', 'B'].map((slot) => ( 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][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`,
)).join('\n'), `[1:${values[slot][proxyDirection]}] -A ${tableChain}_${slot}_P -m comment --comment "harbor-traffic:${keys[slot]}:${proxyKind}" -j RETURN`,
]).join('\n'),
stderr: '', stderr: '',
}; };
}; };
@@ -215,6 +268,7 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
uploadChain, uploadChain,
downloadChain, downloadChain,
bypassCidrs: [], bypassCidrs: [],
proxyPort: 8080,
run, run,
nextGeneration: () => generations.shift(), 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(); const first = await service.refresh();
assert.equal(first.epoch, 'epoch-1'); assert.equal(first.epoch, 'epoch-1');
assert.equal(first.generation, 'rules-a'); assert.equal(first.generation, 'rules-a');
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes }) => ({ assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
mac, uploadBytes, downloadBytes, mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
})), [{ })), [{
mac: firstObservation.mac, mac: firstObservation.mac,
uploadBytes: '100', uploadBytes: '100',
downloadBytes: '200', downloadBytes: '200',
proxyUploadBytes: '30',
proxyDownloadBytes: '40',
}]); }]);
values.A = { upload: '130', download: '240' }; values.A = { upload: '130', download: '240', proxyUpload: '35', proxyDownload: '48' };
observed = { observed = {
observedAt: '2026-08-07T12:01:00.000Z', observedAt: '2026-08-07T12:01:00.000Z',
observations: [secondObservation], 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.epoch, 'epoch-1');
assert.equal(pending.generation, 'rules-b'); assert.equal(pending.generation, 'rules-b');
assert.match(pending.source.error, /retired slot read failed/); assert.match(pending.source.error, /retired slot read failed/);
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes }) => ({ assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
mac, uploadBytes, downloadBytes, mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
})), [ })), [
{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200' }, { mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200', proxyUploadBytes: '30', proxyDownloadBytes: '40' },
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' }, { mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3' },
]); ]);
const finalized = await service.refresh(); const finalized = await service.refresh();
assert.equal(finalized.generation, 'rules-b'); assert.equal(finalized.generation, 'rules-b');
assert.equal(finalized.source.error, null); assert.equal(finalized.source.error, null);
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes }) => ({ assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
mac, uploadBytes, downloadBytes, mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
})), [ })), [
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' }, { mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48' },
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' }, { 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(); const polled = await service.refresh();
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes }) => ({ assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({
mac, uploadBytes, downloadBytes, mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes,
})), [ })), [
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' }, { mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48' },
{ mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17' }, { mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17', proxyUploadBytes: '4', proxyDownloadBytes: '6' },
]); ]);
}); });
+2
View File
@@ -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.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 raw -I PREROUTING 1 -j "\$TRAFFIC_UPLOAD_CHAIN"/);
assert.match(entrypoint, /-t mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_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 "\$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 tcp -j TPROXY/);
assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p udp -j TPROXY/); assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p udp -j TPROXY/);
+12 -4
View File
@@ -40,7 +40,15 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /device\.confidence === 'ambiguous'/); assert.match(panel, /device\.confidence === 'ambiguous'/);
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/); assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
assert.match(panel, /Трафик временно не обновляется/); 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.match(panel, /client-device-traffic-slot[\s\S]*client-device-pin-wrap/);
assert.doesNotMatch(panel, /client-device-details/); assert.doesNotMatch(panel, /client-device-details/);
assert.match(panel, /api\.devices\.setPolicy\(device\.id, mode, snapshot\.revision\)/); 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 Б'); assert.equal(formatByteString('invalid'), '0 Б');
const devices = [ const devices = [
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0' }, { id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
{ id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' }, { id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' },
{ id: 'c', uploadBytes: '10', downloadBytes: '10' }, { id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' },
{ id: 'd', uploadBytes: '15', downloadBytes: '5' }, { id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' },
]; ];
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']); assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']);
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']); assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']);