diff --git a/README.md b/README.md index 3585b52..11b5bc4 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,11 @@ http://АДРЕС-GATEWAY:3456 ### Устройства 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`. -Список приблизительный: 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 diff --git a/entrypoint.sh b/entrypoint.sh index 7f7581f..9aaee84 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -86,8 +86,12 @@ cleanup_device_traffic() { ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true 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}_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 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 done 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 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}_P" || 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 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 diff --git a/src/server/dataplane.js b/src/server/dataplane.js index e0e2068..2d6a29b 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -20,6 +20,7 @@ const traffic = createDeviceTrafficService({ uploadChain: settings.trafficUploadChain, downloadChain: settings.trafficDownloadChain, bypassCidrs: settings.bypassCidrs, + proxyPort: settings.proxyPort, }); const devicePolicy = createDevicePolicyService({ chain: settings.devicePolicyChain, diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index 6caf606..fed7eea 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -15,6 +15,7 @@ const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/; const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i; const POLICY_MODES = new Set(['vpn', 'direct']); const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']); +const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных'; const DEFAULT_DEVICE_POLICY = Object.freeze({ desired: 'vpn', @@ -36,6 +37,15 @@ const DEFAULT_POLICY_STATE = { byMac: {}, }; +const DEFAULT_PROXY_TRAFFIC = { + schemaVersion: 1, + lastObservedAt: null, + lastError: null, + baselinesByMac: {}, + totalsByMac: {}, + rebaselineMacs: [], +}; + const DEFAULT_STATE = { schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, revision: 0, @@ -50,6 +60,7 @@ const DEFAULT_STATE = { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [], + proxy: DEFAULT_PROXY_TRAFFIC, }, devices: [], }; @@ -65,6 +76,68 @@ const parseStoredCounter = (value) => { 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) { const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const byMac = {}; @@ -142,6 +215,7 @@ export function migrateDeviceInventoryState(value) { ? state.traffic : {}; const devices = Array.isArray(state.devices) ? state.devices : []; + const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices); const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); let recoveredTraffic = version >= 2 && ( @@ -202,6 +276,7 @@ export function migrateDeviceInventoryState(value) { baselinesByMac, totalsByMac, rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)), + proxy: proxyTraffic, }, devices, }; @@ -276,6 +351,7 @@ export function createDeviceInventoryService({ const rank = { online: 0, recent: 1, offline: 2 }; const devices = state.devices.map((device) => { const traffic = state.traffic.totalsByMac[device.mac]; + const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac]; const policy = policyFor(state, device.mac); return { ...device, @@ -283,6 +359,9 @@ export function createDeviceInventoryService({ uploadBytes: traffic?.uploadBytes || '0', downloadBytes: traffic?.downloadBytes || '0', trafficObservedAt: traffic?.observedAt || null, + proxyUploadBytes: proxyTraffic?.uploadBytes || '0', + proxyDownloadBytes: proxyTraffic?.downloadBytes || '0', + proxyTrafficObservedAt: proxyTraffic?.observedAt || null, desiredPolicy: policy.desired, appliedPolicy: policy.applied, policyStatus: policy.status, @@ -303,6 +382,10 @@ export function createDeviceInventoryService({ traffic: { lastObservedAt: state.traffic.lastObservedAt, error: state.traffic.lastError, + proxy: { + lastObservedAt: state.traffic.proxy.lastObservedAt, + error: state.traffic.proxy.lastError, + }, }, policy: { lastAppliedAt: state.policy.lastAppliedAt, @@ -489,8 +572,13 @@ export function createDeviceInventoryService({ if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) { throw new Error('Dataplane не вернул traffic epoch'); } + const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : []; 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 upload = String(row?.uploadBytes ?? ''); const download = String(row?.downloadBytes ?? ''); @@ -502,7 +590,30 @@ export function createDeviceInventoryService({ upload: previous.upload + BigInt(upload), 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 baselinesByMac = { ...traffic.baselinesByMac }; const totalsByMac = { ...traffic.totalsByMac }; @@ -546,6 +657,73 @@ export function createDeviceInventoryService({ 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, epoch: trafficResult.epoch, @@ -556,6 +734,7 @@ export function createDeviceInventoryService({ baselinesByMac, totalsByMac, rebaselineMacs: [...rebaselineMacs], + proxy, }; } catch (error) { traffic = { ...traffic, lastError: error.message || String(error) }; diff --git a/src/server/services/deviceTrafficService.js b/src/server/services/deviceTrafficService.js index 96691bc..dc287e3 100644 --- a/src/server/services/deviceTrafficService.js +++ b/src/server/services/deviceTrafficService.js @@ -1,12 +1,20 @@ import crypto from 'node:crypto'; 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 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 proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`; const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto .createHash('sha256') .update(`${ip}|${mac}|${deviceInterface}`) @@ -19,6 +27,55 @@ function commandError(command, result) { ).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) { const candidates = new Map(); const ipsByMac = new Map(); @@ -49,57 +106,72 @@ export function selectTrafficDevices(observations) { )); } -export function buildTrafficRuleCommands({ +export function buildTrafficRestore({ devices, bypassCidrs, uploadChain, downloadChain, 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 downloadChild = childChain(downloadChain, slot); - const commands = [ - ['iptables', ['-w', '1', '-t', 'raw', '-F', uploadChild]], - ['iptables', ['-w', '1', '-t', 'mangle', '-F', downloadChild]], - ['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-i', 'br-+', '-j', 'RETURN']], + const proxyUploadChild = proxyChildChain(uploadChain, slot); + const proxyDownloadChild = proxyChildChain(downloadChain, slot); + const raw = [ + '*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) { - commands.push( - ['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-d', cidr, '-j', 'RETURN']], - ['iptables', ['-w', '1', '-t', 'mangle', '-A', downloadChild, '-s', cidr, '-j', 'RETURN']], - ); + raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`); + mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`); } for (const device of devices) { - commands.push( - ['iptables', [ - '-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', - ]], - ); + 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`); + mangle.push(`-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) { const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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(); for (const line of String(text || '').split(/\r?\n/)) { const match = line.match(linePattern); 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; } @@ -109,7 +181,8 @@ export function createDeviceTrafficService({ uploadChain, downloadChain, bypassCidrs, - run = spawnSync, + proxyPort, + run = runCommand, nextGeneration = () => crypto.randomUUID(), }) { const epoch = nextGeneration(); @@ -129,23 +202,25 @@ export function createDeviceTrafficService({ devices: [], }; - function execute(command, args) { - const result = run(command, args, COMMAND_OPTIONS); + async function execute(command, args, options = COMMAND_OPTIONS) { + const result = await run(command, args, options); if (result.error || result.status !== 0) throw commandError(command, result); return String(result.stdout || ''); } - function prepare(slot, devices) { - for (const [command, args] of buildTrafficRuleCommands({ + async function prepare(slot, devices) { + const input = buildTrafficRestore({ devices, bypassCidrs, uploadChain, downloadChain, 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 downloadChild = childChain(downloadChain, slot); 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, '-j', downloadChild]; - execute('iptables', uploadArgs); + await execute('iptables', uploadArgs); try { - execute('iptables', downloadArgs); + await execute('iptables', downloadArgs); } catch (error) { const rollbackArgs = activeSlot ? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)] : ['-w', '1', '-t', 'raw', '-F', uploadChain]; - execute('iptables', rollbackArgs); + await execute('iptables', rollbackArgs); throw error; } } - function readCounters(devices, slot) { + async function readCounters(devices, slot) { if (!slot) return new Map(); - const upload = parseTrafficCounters( + const [raw, mangle] = await Promise.all([ execute('iptables-save', ['-c', '-t', 'raw']), - childChain(uploadChain, slot), - ); - const download = parseTrafficCounters( 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(); for (const { key } of devices) { - counters.set(`${key}:upload`, upload.get(`${key}:upload`) || '0'); - counters.set(`${key}:download`, download.get(`${key}:download`) || '0'); + for (const [kind, field] of COUNTERS) { + counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0'); + } } return counters; } @@ -194,15 +272,14 @@ export function createDeviceTrafficService({ for (const device of devices) devicesByKey.set(device.key, device); } - function finalizeRetired() { + async function finalizeRetired() { 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) { - const previous = finalized.get(key) || { upload: 0n, download: 0n }; - finalized.set(key, { - upload: previous.upload + counter(counters, key, 'upload'), - download: previous.download + counter(counters, key, 'download'), - }); + const previous = finalized.get(key) || zeroCounters(); + const next = { ...previous }; + for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind); + finalized.set(key, next); } pendingRetired = null; return true; @@ -212,27 +289,21 @@ export function createDeviceTrafficService({ const activeByMac = new Map(activeDevices.map((device) => [device.mac, device])); const totalsByMac = new Map(); 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 upload = base.upload - + counter(pending, key, 'upload') - + counter(activeCounters, key, 'upload'); - const download = base.download - + counter(pending, key, 'download') - + counter(activeCounters, key, 'download'); - const previous = totalsByMac.get(remembered.mac) || { upload: 0n, download: 0n }; - totalsByMac.set(remembered.mac, { - ...(activeByMac.get(remembered.mac) || remembered), - upload: previous.upload + upload, - download: previous.download + download, - }); + const previous = totalsByMac.get(remembered.mac) || zeroCounters(); + const total = { ...(activeByMac.get(remembered.mac) || remembered) }; + for (const [kind, field] of COUNTERS) { + total[field] = previous[field] + base[field] + + counter(pending, key, kind) + counter(activeCounters, key, kind); + } + totalsByMac.set(remembered.mac, total); } return [...totalsByMac.values()] - .map(({ key: _key, upload, download, ...device }) => ({ - ...device, - uploadBytes: upload.toString(), - downloadBytes: download.toString(), - })) + .map((total) => Object.fromEntries([ + ...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)), + ...COUNTERS.map(([, field, output]) => [output, total[field].toString()]), + ])) .sort((left, right) => left.mac.localeCompare(right.mac)); } @@ -253,7 +324,7 @@ export function createDeviceTrafficService({ if (pendingRetired) { try { - countersRead = finalizeRetired() || countersRead; + countersRead = await finalizeRetired() || countersRead; } catch (error) { sourceError = sourceError || error.message || String(error); } @@ -262,8 +333,8 @@ export function createDeviceTrafficService({ if (!pendingRetired && !sourceError && nextSignature !== activeSignature) { const nextSlot = activeSlot === 'A' ? 'B' : 'A'; try { - prepare(nextSlot, nextDevices); - switchTo(nextSlot); + await prepare(nextSlot, nextDevices); + await switchTo(nextSlot); const retired = activeSlot ? { slot: activeSlot, devices: activeDevices, @@ -276,14 +347,14 @@ export function createDeviceTrafficService({ pendingRetired = retired; remember(nextDevices); current.generation = nextGeneration(); - if (pendingRetired) countersRead = finalizeRetired() || countersRead; + if (pendingRetired) countersRead = await finalizeRetired() || countersRead; } catch (error) { sourceError = error.message || String(error); } } try { - activeCounters = readCounters(activeDevices, activeSlot); + activeCounters = await readCounters(activeDevices, activeSlot); countersRead = true; } catch (error) { sourceError = sourceError || error.message || String(error); diff --git a/src/shared/versions.js b/src/shared/versions.js index da63e63..2423f56 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.12.1', - gatewayClient: '0.12.1', - gatewayBackend: '0.12.0', + macClient: '0.12.2', + gatewayClient: '0.13.0', + gatewayBackend: '0.13.0', }); export function parseVersion(value) { diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 2174bfc..367d255 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import { + byteString, formatByteString, formatLastSeen, sortDevicesByTraffic, @@ -210,6 +211,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {

Устройства

Устройства, которые Gateway видит в локальной таблице соседей.

+

Учитывается только трафик, который прошёл через Harbor.

@@ -223,6 +225,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { Трафик временно не обновляется. Показаны последние сохранённые значения.

)} + {snapshot?.source?.traffic?.proxy?.error && ( +

+ Прокси-трафик временно не обновляется. Показаны последние сохранённые значения. +

+ )} {snapshot?.source?.policy?.error && (

Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние. @@ -248,6 +255,16 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { const online = device.status === 'online'; const download = formatByteString(device.downloadBytes); 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 policyFailed = device.policyStatus === 'failed'; const policyPending = device.policyStatus === 'pending'; @@ -314,11 +331,16 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { )} - {device.trafficObservedAt && 0n || proxyTotal > 0n) && - + + {trafficLabel} } diff --git a/src/web/styles.css b/src/web/styles.css index 12de6cc..315a7f6 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1071,8 +1071,12 @@ p { } .client-device-traffic { + display: block; + max-width: min(240px, 42vw); + overflow: hidden; color: var(--client-text); font-variant-numeric: tabular-nums; + text-overflow: ellipsis; white-space: nowrap; } @@ -1081,6 +1085,44 @@ p { 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 { width: 72px; position: relative; diff --git a/src/web/utils/format.js b/src/web/utils/format.js index fe6fd03..064da80 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -36,8 +36,10 @@ export function sortDevicesByTraffic(devices, direction = 'desc') { return (Array.isArray(devices) ? devices : []) .map((device, index) => ({ device, index })) .sort((left, right) => { - const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes); - const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes); + const leftTotal = byteString(left.device.uploadBytes) + byteString(left.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; return (leftTotal < rightTotal ? -1 : 1) * factor; }) diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index c23cd3d..ae144d2 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -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); +}); diff --git a/test/server/device-traffic.test.js b/test/server/device-traffic.test.js index ca39316..208d9ce 100644 --- a/test/server/device-traffic.test.js +++ b/test/server/device-traffic.test.js @@ -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' }, ]); }); diff --git a/test/server/entrypoint-tproxy.test.js b/test/server/entrypoint-tproxy.test.js index 2fa8713..5bc4494 100644 --- a/test/server/entrypoint-tproxy.test.js +++ b/test/server/entrypoint-tproxy.test.js @@ -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/); diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index bd3c4ae..848b28d 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -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 && Gateway/); + assert.match(panel, /proxyTotal > 0n && Прокси/); + 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']);