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.
+ Прокси-трафик временно не обновляется. Показаны последние сохранённые значения. +
+ )} {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) &&
-
+
+