diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index 438a1a0..1de7368 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -111,7 +111,7 @@ jobs: DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}" UPDATE_DATAPLANE=false if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \ - '^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/shared/errors\.js)$'; then + '^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/server/(adapters/neighbors|services/deviceTrafficService)\.js|src/shared/errors\.js)$'; then UPDATE_DATAPLANE=true fi diff --git a/entrypoint.sh b/entrypoint.sh index 31eb0bb..a76a126 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,6 +8,8 @@ TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}" GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}" +TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}" +TRAFFIC_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}" GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}" PROXY_PORT="${PROXY_PORT:-8080}" PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" @@ -15,6 +17,7 @@ PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}" PROXY_FIREWALL="${PROXY_FIREWALL:-true}" PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}" BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}" +export TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS log() { printf '[gateway-entrypoint] %s\n' "$*" @@ -28,6 +31,10 @@ ipt() { iptables -w "$@" } +ipt_traffic() { + iptables -w 1 "$@" +} + cleanup_proxy_firewall() { ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true @@ -52,6 +59,34 @@ cleanup_gateway_forwarding() { ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true } +cleanup_device_traffic() { + ipt_traffic -t raw -D PREROUTING -j "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true + ipt_traffic -t mangle -D POSTROUTING -j "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true + ipt_traffic -t raw -F "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true + 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 -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 -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true + done + ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true + ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true +} + +setup_device_traffic() { + log "setup raw device traffic counters" + cleanup_device_traffic + ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1 + 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 mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || 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 +} + enable_ip_forwarding() { if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then printf '1' > /proc/sys/net/ipv4/ip_forward || true @@ -114,6 +149,10 @@ setup_tproxy() { setup_gateway_forwarding setup_tproxy +if ! setup_device_traffic; then + log "device traffic counters unavailable; VPN routing remains active" + cleanup_device_traffic +fi setup_proxy_firewall if [[ "$APP_COMPONENT" == "dataplane" ]]; then @@ -127,6 +166,7 @@ shutdown() { kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true cleanup_proxy_firewall + cleanup_device_traffic cleanup_tproxy cleanup_gateway_forwarding } @@ -135,6 +175,7 @@ trap 'shutdown; exit 0' SIGTERM SIGINT wait "$APP_PID" STATUS=$? cleanup_proxy_firewall +cleanup_device_traffic cleanup_tproxy cleanup_gateway_forwarding exit "$STATUS" diff --git a/src/server/config.js b/src/server/config.js index b54dcf2..2e6b846 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -16,6 +16,11 @@ export const settings = { proxyPort, tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", + trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP", + trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN", + bypassCidrs: (process.env.BYPASS_CIDRS + || "0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4") + .trim().split(/\s+/).filter(Boolean), dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock", bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, diff --git a/src/server/dataplane.js b/src/server/dataplane.js index 4c6193d..dd236a1 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -5,6 +5,7 @@ import { settings } from './config.js'; import { createSingboxRuntime } from './singboxRuntime.js'; import { buildVersionInfo } from './version.js'; import { readNeighborSnapshot } from './adapters/neighbors.js'; +import { createDeviceTrafficService } from './services/deviceTrafficService.js'; const socketPath = settings.dataplaneSocket; const runtime = createSingboxRuntime({ @@ -13,7 +14,14 @@ const runtime = createSingboxRuntime({ tproxyChain: settings.tproxyChain, }); const versionInfo = buildVersionInfo('gateway'); +const traffic = createDeviceTrafficService({ + observe: () => readNeighborSnapshot(), + uploadChain: settings.trafficUploadChain, + downloadChain: settings.trafficDownloadChain, + bypassCidrs: settings.bypassCidrs, +}); let ready = false; +let trafficTimer = null; function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); @@ -33,6 +41,9 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/devices') { return sendJson(res, 200, readNeighborSnapshot()); } + if (req.method === 'GET' && req.url === '/device-traffic') { + return sendJson(res, 200, traffic.snapshot()); + } if (req.method === 'POST' && req.url === '/apply') { return sendJson(res, 200, await runtime.apply()); } @@ -58,6 +69,14 @@ server.listen(socketPath, async () => { console.warn(`[dataplane] sing-box не запущен: ${error.message}`); } finally { ready = true; + setImmediate(() => { + traffic.refresh() + .catch((error) => console.warn(`[dataplane] traffic counters не запущены: ${error.message}`)); + }); + trafficTimer = setInterval(() => { + traffic.refresh().catch((error) => console.warn(`[dataplane] traffic counters не обновлены: ${error.message}`)); + }, 15_000); + trafficTimer.unref(); console.log(`[dataplane] control socket: ${socketPath}`); } }); @@ -67,6 +86,7 @@ async function shutdown() { if (shuttingDown) return; shuttingDown = true; ready = false; + if (trafficTimer) clearInterval(trafficTimer); await runtime.shutdown(); server.close(() => { fs.rmSync(socketPath, { force: true }); diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js index 48c0d56..9d09394 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.js @@ -44,6 +44,7 @@ export function createDataplaneClient(socketPath, send = request) { get startedAt() { return current.startedAt || null; }, refresh: () => update('/status', 'GET'), observeDevices: () => send(socketPath, '/devices', 'GET'), + observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), apply: () => update('/apply', 'POST'), restart: () => update('/restart', 'POST'), stop: () => update('/stop', 'POST'), diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index dd6a769..db32548 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; +import net from 'node:net'; import { HarborError } from '../../shared/errors.js'; const SCHEMA_VERSION = 1; @@ -100,6 +101,13 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu } const observedAt = result?.observedAt || now().toISOString(); const observations = Array.isArray(result?.observations) ? result.observations : []; + const ipsByMac = new Map(); + for (const observation of observations) { + const mac = normalizeMac(observation.mac); + if (!mac || !net.isIPv4(String(observation.ip || ''))) continue; + if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set()); + ipsByMac.get(mac).add(String(observation.ip)); + } store.update((stored) => { const state = migrate(stored); const byMac = new Map(state.devices.map((device) => [device.mac, device])); @@ -122,7 +130,9 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt, lastSeenAt, source: 'neighbor', - confidence: isPrivateMac(mac) ? 'medium' : 'high', + confidence: ipsByMac.get(mac)?.size > 1 + ? 'ambiguous' + : isPrivateMac(mac) ? 'medium' : 'high', }); } const cutoff = new Date(observedAt).getTime() - RETENTION_MS; diff --git a/src/server/services/deviceTrafficService.js b/src/server/services/deviceTrafficService.js new file mode 100644 index 0000000..550e28f --- /dev/null +++ b/src/server/services/deviceTrafficService.js @@ -0,0 +1,240 @@ +import crypto from 'node:crypto'; +import net from 'node:net'; +import { spawnSync } 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 childChain = (chain, slot) => `${chain}_${slot}`; +const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto + .createHash('sha256') + .update(`${ip}|${mac}|${deviceInterface}`) + .digest('hex') + .slice(0, 16); + +function commandError(command, result) { + return new Error(String( + result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`, + ).trim()); +} + +export function selectTrafficDevices(observations) { + const candidates = new Map(); + const ipsByMac = new Map(); + const locationsByIp = new Map(); + + for (const observation of Array.isArray(observations) ? observations : []) { + const ip = String(observation?.ip || ''); + const mac = String(observation?.mac || '').toLowerCase(); + const deviceInterface = String(observation?.interface || ''); + if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !INTERFACE_PATTERN.test(deviceInterface) + || deviceInterface.startsWith('br-')) continue; + + const location = `${mac}|${deviceInterface}`; + candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface }); + if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set()); + ipsByMac.get(mac).add(ip); + if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set()); + locationsByIp.get(ip).add(location); + } + + return [...candidates.values()] + .filter(({ ip, mac }) => ipsByMac.get(mac).size === 1 && locationsByIp.get(ip).size === 1) + .map((device) => ({ ...device, key: counterKey(device) })) + .sort((left, right) => ( + left.ip.localeCompare(right.ip) + || left.mac.localeCompare(right.mac) + || left.interface.localeCompare(right.interface) + )); +} + +export function buildTrafficRuleCommands({ + devices, + bypassCidrs, + uploadChain, + downloadChain, + slot, +}) { + 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']], + ]; + + 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']], + ); + } + 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', + ]], + ); + } + return commands; +} + +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)"?`, + ); + 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]); + } + return counters; +} + +export function createDeviceTrafficService({ + observe, + uploadChain, + downloadChain, + bypassCidrs, + run = spawnSync, + nextGeneration = () => crypto.randomUUID(), +}) { + let activeSlot = null; + let activeDevices = []; + let activeSignature = ''; + let refreshPromise = null; + let current = { + generation: nextGeneration(), + observedAt: null, + source: { error: null }, + devices: [], + }; + + function execute(command, args) { + const result = run(command, args, COMMAND_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({ + devices, + bypassCidrs, + uploadChain, + downloadChain, + slot, + })) execute(command, args); + } + + function switchTo(slot) { + const uploadChild = childChain(uploadChain, slot); + const downloadChild = childChain(downloadChain, slot); + const replace = activeSlot ? '-R' : '-A'; + const uploadArgs = activeSlot + ? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild] + : ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild]; + const downloadArgs = activeSlot + ? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild] + : ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild]; + + execute('iptables', uploadArgs); + try { + 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); + throw error; + } + } + + function readCounters(devices) { + if (!activeSlot) return []; + const upload = parseTrafficCounters( + execute('iptables-save', ['-c', '-t', 'raw']), + childChain(uploadChain, activeSlot), + ); + const download = parseTrafficCounters( + execute('iptables-save', ['-c', '-t', 'mangle']), + childChain(downloadChain, activeSlot), + ); + return devices.map(({ key, ...device }) => ({ + ...device, + uploadBytes: upload.get(`${key}:upload`) || '0', + downloadBytes: download.get(`${key}:download`) || '0', + })); + } + + async function performRefresh() { + let observed; + try { + observed = await observe(); + } catch (error) { + observed = { observedAt: new Date().toISOString(), observations: [], error: error.message || String(error) }; + } + + let sourceError = observed?.error || null; + const nextDevices = sourceError + ? activeDevices + : selectTrafficDevices(observed?.observations); + const nextSignature = JSON.stringify(nextDevices); + + if (!sourceError && nextSignature !== activeSignature) { + const nextSlot = activeSlot === 'A' ? 'B' : 'A'; + try { + prepare(nextSlot, nextDevices); + switchTo(nextSlot); + activeSlot = nextSlot; + activeDevices = nextDevices; + activeSignature = nextSignature; + current.generation = nextGeneration(); + } catch (error) { + sourceError = error.message || String(error); + } + } + + let devices = current.devices; + let countersRead = false; + try { + devices = readCounters(activeDevices); + countersRead = true; + } catch (error) { + sourceError = sourceError || error.message || String(error); + } + current = { + generation: current.generation, + observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt, + source: { error: sourceError }, + devices, + }; + return structuredClone(current); + } + + function refresh() { + if (!refreshPromise) { + refreshPromise = performRefresh().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; + } + + return { + refresh, + snapshot: () => structuredClone(current), + }; +} diff --git a/src/shared/versions.js b/src/shared/versions.js index 613385e..39fcbed 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.9.4', - gatewayClient: '0.9.4', - gatewayBackend: '0.9.1', + macClient: '0.10.0', + gatewayClient: '0.10.0', + gatewayBackend: '0.10.0', }); export function parseVersion(value) { diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 3862350..7f8c4a0 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -248,10 +248,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { ⓘ {device.confidence === 'medium' ? 'Устройство использует приватный MAC, производитель может не определиться' - : 'Устройство определено приблизительно'} + : device.confidence === 'ambiguous' + ? 'Один MAC наблюдается у нескольких IP, индивидуальные правила могут быть неточными' + : 'Устройство определено приблизительно'} } } - {device.interface && {device.interface}}