From 560c243047e218058e806ddcca3ba1f1fc98c6d4 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 16:18:31 +0300 Subject: [PATCH] Add per-device VPN and direct routing policies --- .gitea/workflows/gateway-build.yml | 2 +- README.md | 4 +- entrypoint.sh | 36 +- src/server/config.js | 2 + src/server/dataplane.js | 41 ++ src/server/dataplaneClient.js | 17 +- src/server/index.js | 33 +- src/server/services/deviceInventoryService.js | 387 +++++++++++++++--- src/server/services/devicePolicyService.js | 134 ++++++ src/shared/errors.js | 3 + src/shared/versions.js | 6 +- src/web/api.js | 4 + src/web/components/DevicesPanel.jsx | 104 ++++- src/web/styles.css | 99 +++-- test/server/dataplane-client.test.js | 11 +- test/server/deploy-split.test.js | 2 +- test/server/device-inventory.test.js | 174 ++++++++ test/server/device-policy.test.js | 125 ++++++ test/server/entrypoint-tproxy.test.js | 5 +- test/web/device-inventory-contract.test.js | 15 +- 20 files changed, 1090 insertions(+), 114 deletions(-) create mode 100644 src/server/services/devicePolicyService.js create mode 100644 test/server/device-policy.test.js diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index 1de7368..a6cb146 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/server/(adapters/neighbors|services/deviceTrafficService)\.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|devicePolicyService))\.js|src/shared/errors\.js)$'; then UPDATE_DATAPLANE=true fi diff --git a/README.md b/README.md index f15cc7f..3585b52 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей, показывает IP, MAC, последний контакт, производителя из локальной OUI-базы и сохранённые значения полученного/отданного интернет-трафика. Устройство можно переименовать и закрепить; название, закрепление и накопленные traffic totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список от большего объёма к меньшему или наоборот. +После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённые значения полученного/отданного интернет-трафика. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; название, закрепление и накопленные traffic 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 секунд; история по часам пока не хранится. diff --git a/entrypoint.sh b/entrypoint.sh index a76a126..7f7581f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,6 +6,7 @@ TPROXY_PORT="${TPROXY_PORT:-7895}" TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" +DEVICE_POLICY_CHAIN="${DEVICE_POLICY_CHAIN:-VPN_PROXY_DEVICE_POLICY}" 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}" @@ -17,7 +18,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 +export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS log() { printf '[gateway-entrypoint] %s\n' "$*" @@ -50,6 +51,25 @@ cleanup_tproxy() { ip route flush table "$TPROXY_TABLE" 2>/dev/null || true } +cleanup_device_policy() { + ipt -t mangle -F "$DEVICE_POLICY_CHAIN" 2>/dev/null || true + for slot in A B; do + ipt -t mangle -F "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true + ipt -t mangle -X "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true + done + ipt -t mangle -X "$DEVICE_POLICY_CHAIN" 2>/dev/null || true +} + +setup_device_policy() { + cleanup_device_policy + ipt -t mangle -N "$DEVICE_POLICY_CHAIN" || return 1 + ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_A" || return 1 + ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_B" || return 1 + ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1 + ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1 + ipt -t mangle -A "$DEVICE_POLICY_CHAIN" -j "${DEVICE_POLICY_CHAIN}_A" || return 1 +} + cleanup_gateway_forwarding() { ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true @@ -129,6 +149,10 @@ setup_gateway_forwarding() { setup_tproxy() { log "setup tproxy on port ${TPROXY_PORT}" cleanup_tproxy + if ! setup_device_policy; then + log "device policy unavailable; using global VPN fallback" + cleanup_device_policy + fi enable_ip_forwarding ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true @@ -143,8 +167,12 @@ setup_tproxy() { ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN done - ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" - ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" + if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then + ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN" + else + ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" + ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" + fi } setup_gateway_forwarding @@ -168,6 +196,7 @@ shutdown() { cleanup_proxy_firewall cleanup_device_traffic cleanup_tproxy + cleanup_device_policy cleanup_gateway_forwarding } @@ -177,5 +206,6 @@ STATUS=$? cleanup_proxy_firewall cleanup_device_traffic cleanup_tproxy +cleanup_device_policy cleanup_gateway_forwarding exit "$STATUS" diff --git a/src/server/config.js b/src/server/config.js index 2e6b846..106a764 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -15,7 +15,9 @@ export const settings = { port: parsePort(process.env.PORT, 3456), proxyPort, tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), + tproxyMark: process.env.TPROXY_MARK || "1", tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", + devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY", 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 diff --git a/src/server/dataplane.js b/src/server/dataplane.js index dd236a1..e0e2068 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -6,6 +6,7 @@ import { createSingboxRuntime } from './singboxRuntime.js'; import { buildVersionInfo } from './version.js'; import { readNeighborSnapshot } from './adapters/neighbors.js'; import { createDeviceTrafficService } from './services/deviceTrafficService.js'; +import { createDevicePolicyService } from './services/devicePolicyService.js'; const socketPath = settings.dataplaneSocket; const runtime = createSingboxRuntime({ @@ -20,8 +21,40 @@ const traffic = createDeviceTrafficService({ downloadChain: settings.trafficDownloadChain, bypassCidrs: settings.bypassCidrs, }); +const devicePolicy = createDevicePolicyService({ + chain: settings.devicePolicyChain, + tproxyPort: settings.tproxyPort, + tproxyMark: settings.tproxyMark, +}); let ready = false; let trafficTimer = null; +const MAX_POLICY_BODY_BYTES = 256 * 1024; + +function readJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let tooLarge = false; + req.on('data', (chunk) => { + size += chunk.length; + if (!tooLarge && size > MAX_POLICY_BODY_BYTES) { + tooLarge = true; + reject(new Error('Device policy request слишком большой')); + return; + } + if (!tooLarge) chunks.push(chunk); + }); + req.on('end', () => { + if (tooLarge) return; + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); + } catch { + reject(new Error('Device policy request содержит невалидный JSON')); + } + }); + req.on('error', reject); + }); +} function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); @@ -35,6 +68,7 @@ const server = http.createServer(async (req, res) => { ...await runtime.refresh(), gatewayBackendVersion: versionInfo.components.gatewayBackend, singBoxVersion: versionInfo.runtime.singBox, + devicePolicy: devicePolicy.snapshot(), ready, }); } @@ -44,6 +78,13 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/device-traffic') { return sendJson(res, 200, traffic.snapshot()); } + if (req.method === 'GET' && req.url === '/device-policy') { + return sendJson(res, 200, devicePolicy.snapshot()); + } + if (req.method === 'PUT' && req.url === '/device-policy') { + const body = await readJson(req); + return sendJson(res, 200, await devicePolicy.apply(body.devices)); + } if (req.method === 'POST' && req.url === '/apply') { return sendJson(res, 200, await runtime.apply()); } diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js index 9d09394..21ce2d4 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.js @@ -1,9 +1,18 @@ import http from 'node:http'; import { HarborError } from '../shared/errors.js'; -function request(socketPath, pathname, method = 'GET') { +function request(socketPath, pathname, method = 'GET', body = null) { return new Promise((resolve, reject) => { - const req = http.request({ socketPath, path: pathname, method }, (res) => { + const encoded = body == null ? null : JSON.stringify(body); + const req = http.request({ + socketPath, + path: pathname, + method, + headers: encoded ? { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(encoded), + } : {}, + }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { @@ -21,7 +30,7 @@ function request(socketPath, pathname, method = 'GET') { }); req.on('error', reject); req.setTimeout(6000, () => req.destroy(new Error('Dataplane не ответил за 6 секунд'))); - req.end(); + req.end(encoded); }); } @@ -45,6 +54,8 @@ export function createDataplaneClient(socketPath, send = request) { refresh: () => update('/status', 'GET'), observeDevices: () => send(socketPath, '/devices', 'GET'), observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), + observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), + applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }), apply: () => update('/apply', 'POST'), restart: () => update('/restart', 'POST'), stop: () => update('/stop', 'POST'), diff --git a/src/server/index.js b/src/server/index.js index c8435f0..78c6ae7 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -39,6 +39,7 @@ import { import { HarborError, normalizeHarborError } from '../shared/errors.js'; import { normalizeRouteRules } from '../shared/routingRules.js'; import { createJsonStore, createStateStore } from './services/stateStore.js'; +import { createDevicePolicyService } from './services/devicePolicyService.js'; import { createDeviceInventoryService, createVendorLookup, @@ -108,6 +109,13 @@ const singboxRuntime = remoteDataplane gateway: settings.appMode === 'gateway', tproxyChain: settings.tproxyChain, }); +const localDevicePolicy = settings.appMode === 'gateway' && !remoteDataplane + ? createDevicePolicyService({ + chain: settings.devicePolicyChain, + tproxyPort: settings.tproxyPort, + tproxyMark: settings.tproxyMark, + }) + : null; const deviceInventory = settings.appMode === 'gateway' ? createDeviceInventoryService({ store: deviceStore, @@ -117,6 +125,12 @@ const deviceInventory = settings.appMode === 'gateway' observeTraffic: remoteDataplane ? () => singboxRuntime.observeTraffic() : null, + observePolicy: remoteDataplane + ? () => singboxRuntime.observeDevicePolicy() + : () => localDevicePolicy.snapshot(), + applyPolicies: remoteDataplane + ? (devices) => singboxRuntime.applyDevicePolicies(devices) + : (devices) => localDevicePolicy.apply(devices), vendor: createVendorLookup(), }) : null; @@ -644,7 +658,19 @@ async function handleApi(req, res) { if (deviceMatch && req.method === 'PUT') { if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND'); const body = await readBody(req); - return sendJson(res, 200, deviceInventory.update(deviceMatch[1], body, body.expectedRevision)); + const { expectedRevision, ...patch } = body; + return sendJson(res, 200, deviceInventory.update(deviceMatch[1], patch, expectedRevision)); + } + + const devicePolicyMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/); + if (devicePolicyMatch && req.method === 'PUT') { + if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND'); + const body = await readBody(req); + return sendJson(res, 200, await deviceInventory.setPolicy( + devicePolicyMatch[1], + body.mode, + body.expectedRevision, + )); } if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') { @@ -847,6 +873,11 @@ await startSingbox() }) .catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`)); +if (deviceInventory) { + await deviceInventory.reconcilePolicies() + .catch((error) => console.warn(`[control] device policy не применена: ${error.message}`)); +} + server.listen(settings.port, '0.0.0.0', () => { console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); }); diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index 8a90f78..6caf606 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -2,19 +2,46 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import net from 'node:net'; import { HarborError } from '../../shared/errors.js'; +import { fingerprintDirectDevices } from './devicePolicyService.js'; export const DEVICE_INVENTORY_SCHEMA_VERSION = 2; const ONLINE_MS = 2 * 60 * 1000; const RECENT_MS = 24 * 60 * 60 * 1000; const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const COUNTER_PATTERN = /^\d+$/; +const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; +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 DEFAULT_DEVICE_POLICY = Object.freeze({ + desired: 'vpn', + applied: 'vpn', + status: 'applied', + appliedAt: null, + error: null, + operationId: null, +}); + +const DEFAULT_POLICY_STATE = { + schemaVersion: 1, + defaultMode: 'vpn', + dataplaneEpoch: null, + generation: null, + fingerprint: null, + lastAppliedAt: null, + lastError: null, + byMac: {}, +}; const DEFAULT_STATE = { schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, revision: 0, lastObservedAt: null, lastError: null, + policy: DEFAULT_POLICY_STATE, traffic: { epoch: null, generation: null, @@ -38,6 +65,49 @@ const parseStoredCounter = (value) => { return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null; }; +function normalizePolicyState(value) { + const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const byMac = {}; + let recovered = value !== undefined && ( + policy.schemaVersion !== 1 + || policy.defaultMode !== 'vpn' + || !policy.byMac || typeof policy.byMac !== 'object' || Array.isArray(policy.byMac) + || (policy.dataplaneEpoch != null && typeof policy.dataplaneEpoch !== 'string') + || (policy.generation != null && typeof policy.generation !== 'string') + || (policy.fingerprint != null && typeof policy.fingerprint !== 'string') + || (policy.lastAppliedAt != null && typeof policy.lastAppliedAt !== 'string') + ); + for (const [rawMac, entry] of recordEntries(policy.byMac)) { + const mac = normalizeMac(rawMac); + if (!MAC_PATTERN.test(mac) || !POLICY_MODES.has(entry?.desired) + || !POLICY_MODES.has(entry?.applied) || !POLICY_STATUSES.has(entry?.status)) { + recovered = true; + continue; + } + byMac[mac] = { + desired: entry.desired, + applied: entry.applied, + status: entry.status, + appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null, + error: typeof entry.error === 'string' ? entry.error : null, + operationId: typeof entry.operationId === 'string' ? entry.operationId : null, + }; + } + return { + ...DEFAULT_POLICY_STATE, + dataplaneEpoch: typeof policy.dataplaneEpoch === 'string' ? policy.dataplaneEpoch : null, + generation: typeof policy.generation === 'string' ? policy.generation : null, + fingerprint: typeof policy.fingerprint === 'string' ? policy.fingerprint : null, + lastAppliedAt: typeof policy.lastAppliedAt === 'string' ? policy.lastAppliedAt : null, + schemaVersion: 1, + defaultMode: 'vpn', + lastError: recovered + ? 'Повреждённый device policy checkpoint восстановлен из корректных данных' + : typeof policy.lastError === 'string' ? policy.lastError : null, + byMac, + }; +} + export function parseOuiVendors(text) { const vendors = new Map(); for (const line of String(text || '').split(/\r?\n/)) { @@ -122,6 +192,7 @@ export function migrateDeviceInventoryState(value) { ...state, schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, revision: Number.isSafeInteger(state.revision) ? state.revision : 0, + policy: normalizePolicyState(state.policy), traffic: { ...DEFAULT_STATE.traffic, ...traffic, @@ -147,10 +218,57 @@ export function createDeviceInventoryService({ store, observe, observeTraffic = null, + observePolicy = null, + applyPolicies = null, vendor = () => null, now = () => new Date(), }) { let refreshPromise = null; + let policyQueue = Promise.resolve(); + + function serializePolicy(action) { + const result = policyQueue.then(action, action); + policyQueue = result.catch(() => {}); + return result; + } + + function policyFor(state, mac) { + return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY; + } + + function policyIdentity(device) { + return device?.pinned && device.confidence !== 'ambiguous' + && net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac) + && INTERFACE_PATTERN.test(String(device.interface || '')) + && !String(device.interface).startsWith('br-'); + } + + function directDevices(state) { + return state.devices + .filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device)) + .map(({ id, ip, mac, interface: deviceInterface }) => ({ + id, + ip, + mac, + interface: deviceInterface, + })); + } + + function validatePolicyAck(result, requested) { + const appliedIds = Array.isArray(result?.appliedIds) ? result.appliedIds : []; + const expectedIds = new Set(requested.map(({ id }) => id)); + if (typeof result?.epoch !== 'string' || !result.epoch + || typeof result.generation !== 'string' || !result.generation + || !FINGERPRINT_PATTERN.test(result.fingerprint) + || typeof result.observedAt !== 'string' || !result.observedAt + || result.fingerprint !== fingerprintDirectDevices(requested) + || appliedIds.length !== expectedIds.size + || new Set(appliedIds).size !== appliedIds.length + || appliedIds.some((id) => !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) { + throw new Error('Dataplane вернул невалидный device policy acknowledgement'); + } + return result; + } function snapshot() { const state = migrateDeviceInventoryState(store.read()); @@ -158,12 +276,18 @@ 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 policy = policyFor(state, device.mac); return { ...device, status: deviceStatus(device.lastSeenAt, current), uploadBytes: traffic?.uploadBytes || '0', downloadBytes: traffic?.downloadBytes || '0', trafficObservedAt: traffic?.observedAt || null, + desiredPolicy: policy.desired, + appliedPolicy: policy.applied, + policyStatus: policy.status, + policyAppliedAt: policy.appliedAt, + policyError: policy.error, }; }).sort((left, right) => ( Number(right.pinned) - Number(left.pinned) @@ -180,13 +304,127 @@ export function createDeviceInventoryService({ lastObservedAt: state.traffic.lastObservedAt, error: state.traffic.lastError, }, + policy: { + lastAppliedAt: state.policy.lastAppliedAt, + error: state.policy.lastError, + }, }, devices, }; } + function markPolicyEpoch(observed) { + if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return; + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state; + const appliedIds = new Set(observed.appliedIds); + const devicesByMac = new Map(state.devices.map((device) => [device.mac, device])); + const byMac = {}; + for (const [mac, entry] of Object.entries(state.policy.byMac)) { + const device = devicesByMac.get(mac); + if (!device) continue; + const applied = appliedIds.has(device.id) ? 'direct' : 'vpn'; + if (entry.desired === 'vpn' && applied === 'vpn') continue; + byMac[mac] = { + ...entry, + applied, + status: entry.desired === applied ? 'applied' : 'pending', + appliedAt: observed.observedAt || entry.appliedAt, + error: entry.desired === applied + ? null + : 'Dataplane перезапущен, маршрут ожидает повторного применения', + operationId: null, + }; + } + return { + ...state, + revision: state.revision + 1, + policy: { + ...state.policy, + dataplaneEpoch: observed.epoch, + generation: typeof observed.generation === 'string' ? observed.generation : null, + fingerprint: FINGERPRINT_PATTERN.test(observed.fingerprint) ? observed.fingerprint : null, + lastAppliedAt: observed.observedAt || state.policy.lastAppliedAt, + lastError: null, + byMac, + }, + }; + }); + } + + function commitPolicySuccess(result) { + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []); + const devicesByMac = new Map(state.devices.map((device) => [device.mac, device])); + const byMac = {}; + for (const [mac, entry] of Object.entries(state.policy.byMac)) { + const device = devicesByMac.get(mac); + if (!device) continue; + if (entry.desired === 'vpn' && !appliedIds.has(device?.id)) continue; + const applied = appliedIds.has(device?.id) ? 'direct' : 'vpn'; + byMac[mac] = { + ...entry, + applied, + status: entry.desired === applied ? 'applied' : 'pending', + appliedAt: result.observedAt || state.policy.lastAppliedAt, + error: entry.desired === applied + ? null + : 'Gateway должен однозначно распознать устройство', + operationId: null, + }; + } + const policy = { + ...state.policy, + dataplaneEpoch: result.epoch, + generation: result.generation, + fingerprint: result.fingerprint, + lastAppliedAt: result.observedAt || state.policy.lastAppliedAt, + lastError: null, + byMac, + }; + if (JSON.stringify(policy) === JSON.stringify(state.policy)) return state; + return { ...state, revision: state.revision + 1, policy }; + }); + } + + function commitPolicyFailure(error) { + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + const message = error.message || String(error); + const byMac = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, { + ...entry, + status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed', + error: entry.status === 'applied' && entry.desired === entry.applied ? null : message, + operationId: null, + }])); + return { + ...state, + revision: state.revision + 1, + policy: { ...state.policy, lastError: message, byMac }, + }; + }); + } + + async function reconcileLocked(observedPolicy, throwOnError) { + if (!applyPolicies) return snapshot(); + markPolicyEpoch(observedPolicy); + const state = migrateDeviceInventoryState(store.read()); + try { + const requested = directDevices(state); + const result = validatePolicyAck(await applyPolicies(requested), requested); + commitPolicySuccess(result); + return snapshot(); + } catch (cause) { + commitPolicyFailure(cause); + if (throwOnError) throw new HarborError('DEVICE_POLICY_APPLY_FAILED', { cause }); + return snapshot(); + } + } + async function performRefresh() { - const [result, trafficResult] = await Promise.all([ + const [result, trafficResult, policyResult] = await Promise.all([ Promise.resolve().then(() => observe()).catch((error) => ({ observedAt: now().toISOString(), observations: [], @@ -196,49 +434,54 @@ export function createDeviceInventoryService({ ? Promise.resolve().then(() => observeTraffic()) .catch((error) => ({ transportError: error.message || String(error) })) : null, + observePolicy + ? Promise.resolve().then(() => observePolicy()) + .catch((error) => ({ transportError: error.message || String(error) })) + : null, ]); const observedAt = result?.observedAt || now().toISOString(); const observations = Array.isArray(result?.observations) ? result.observations : []; - const ipsByMac = new Map(); + const identitiesByMac = 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)); + if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set()); + identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`); } - store.update((stored) => { - const state = migrateDeviceInventoryState(stored); - const byMac = new Map(state.devices.map((device) => [device.mac, device])); - for (const observation of observations) { - const mac = normalizeMac(observation.mac); - if (!mac) continue; - const previous = byMac.get(mac); - const lastSeenAt = observation.active || !previous - ? observation.observedAt || observedAt - : previous.lastSeenAt; - byMac.set(mac, { - id: previous?.id || deviceId(mac), - alias: previous?.alias || '', - pinned: previous?.pinned === true, - hostname: previous?.hostname || null, - manufacturer: previous?.manufacturer || vendor(mac), - mac, - ip: String(observation.ip || previous?.ip || ''), - interface: String(observation.interface || previous?.interface || ''), - firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt, - lastSeenAt, - source: 'neighbor', - confidence: ipsByMac.get(mac)?.size > 1 - ? 'ambiguous' - : isPrivateMac(mac) ? 'medium' : 'high', - }); - } - const cutoff = new Date(observedAt).getTime() - RETENTION_MS; - const devices = [...byMac.values()].filter((device) => ( - device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff - )); - let traffic = state.traffic; - if (trafficResult) { + return serializePolicy(async () => { + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + const byMac = new Map(state.devices.map((device) => [device.mac, device])); + for (const observation of observations) { + const mac = normalizeMac(observation.mac); + if (!mac) continue; + const previous = byMac.get(mac); + const lastSeenAt = observation.active || !previous + ? observation.observedAt || observedAt + : previous.lastSeenAt; + byMac.set(mac, { + id: previous?.id || deviceId(mac), + alias: previous?.alias || '', + pinned: previous?.pinned === true, + hostname: previous?.hostname || null, + manufacturer: previous?.manufacturer || vendor(mac), + mac, + ip: String(observation.ip || previous?.ip || ''), + interface: String(observation.interface || previous?.interface || ''), + firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt, + lastSeenAt, + source: 'neighbor', + confidence: identitiesByMac.get(mac)?.size > 1 + ? 'ambiguous' + : isPrivateMac(mac) ? 'medium' : 'high', + }); + } + const cutoff = new Date(observedAt).getTime() - RETENTION_MS; + const devices = [...byMac.values()].filter((device) => ( + device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff + )); + let traffic = state.traffic; + if (trafficResult) { if (trafficResult.transportError) { traffic = { ...traffic, lastError: trafficResult.transportError }; } else { @@ -318,17 +561,19 @@ export function createDeviceInventoryService({ traffic = { ...traffic, lastError: error.message || String(error) }; } } - } - return { - ...state, - revision: state.revision + 1, - lastObservedAt: observedAt, - lastError: result?.error || null, - traffic, - devices, - }; + } + return { + ...state, + revision: state.revision + 1, + lastObservedAt: observedAt, + lastError: result?.error || null, + traffic, + devices, + }; + }); + if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError)); + return reconcileLocked(policyResult, false); }); - return snapshot(); } function refresh() { @@ -357,6 +602,11 @@ export function createDeviceInventoryService({ if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); const index = state.devices.findIndex((device) => device.id === id); if (index < 0) throw new HarborError('DEVICE_NOT_FOUND'); + const currentPolicy = policyFor(state, state.devices[index].mac); + if (pinProvided && patch.pinned === false + && (currentPolicy.desired === 'direct' || currentPolicy.applied === 'direct')) { + throw new HarborError('REQUEST_INVALID'); + } const devices = [...state.devices]; devices[index] = { ...devices[index], @@ -368,5 +618,46 @@ export function createDeviceInventoryService({ return snapshot(); } - return { snapshot, refresh, update }; + function setPolicy(id, mode, expectedRevision) { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) { + throw new HarborError('REQUEST_INVALID'); + } + return serializePolicy(async () => { + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + const device = state.devices.find((candidate) => candidate.id === id); + if (!device) throw new HarborError('DEVICE_NOT_FOUND'); + if (mode === 'direct' && !device.pinned) throw new HarborError('DEVICE_POLICY_REQUIRES_PIN'); + if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS'); + const current = policyFor(state, device.mac); + if (current.desired === mode && current.status === 'applied') return state; + const byMac = { + ...state.policy.byMac, + [device.mac]: { + ...current, + desired: mode, + status: 'applying', + error: null, + operationId: crypto.randomUUID(), + }, + }; + return { + ...state, + revision: state.revision + 1, + policy: { ...state.policy, lastError: null, byMac }, + }; + }); + return reconcileLocked(null, true); + }); + } + + async function reconcilePolicies() { + const observed = observePolicy + ? await Promise.resolve().then(() => observePolicy()).catch(() => null) + : null; + return serializePolicy(() => reconcileLocked(observed, true)); + } + + return { snapshot, refresh, update, setPolicy, reconcilePolicies }; } diff --git a/src/server/services/devicePolicyService.js b/src/server/services/devicePolicyService.js new file mode 100644 index 0000000..f2150f1 --- /dev/null +++ b/src/server/services/devicePolicyService.js @@ -0,0 +1,134 @@ +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 DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; +const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; +const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i; +const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i; +const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i; +const MAX_DEVICES = 512; + +const childChain = (chain, slot) => `${chain}_${slot}`; +const fingerprint = (devices) => crypto.createHash('sha256') + .update(JSON.stringify(devices)) + .digest('hex'); + +function commandError(command, result) { + return new Error(String( + result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`, + ).trim()); +} + +export function normalizeDirectDevices(value) { + if (!Array.isArray(value) || value.length > MAX_DEVICES) { + throw new Error('Некорректный набор device policy'); + } + const ids = new Set(); + const tuples = new Set(); + const devices = value.map((device) => { + const normalized = { + id: String(device?.id || ''), + ip: String(device?.ip || ''), + mac: String(device?.mac || '').toLowerCase(), + interface: String(device?.interface || ''), + }; + const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`; + if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip) + || !MAC_PATTERN.test(normalized.mac) || !INTERFACE_PATTERN.test(normalized.interface) + || normalized.interface.startsWith('br-') || ids.has(normalized.id) || tuples.has(tuple)) { + throw new Error('Некорректная или повторяющаяся device policy identity'); + } + ids.add(normalized.id); + tuples.add(tuple); + return normalized; + }); + return devices.sort((left, right) => left.id.localeCompare(right.id)); +} + +export const fingerprintDirectDevices = (value) => fingerprint(normalizeDirectDevices(value)); + +export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }) { + const child = childChain(chain, slot); + const rules = ['*mangle', `-F ${child}`]; + for (const device of devices) { + rules.push(`-A ${child} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-policy:${device.id}:direct -j RETURN`); + } + rules.push( + `-A ${child} -p tcp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`, + `-A ${child} -p udp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`, + 'COMMIT', + '', + ); + return rules.join('\n'); +} + +export function createDevicePolicyService({ + chain, + tproxyPort, + tproxyMark, + run = spawnSync, + now = () => new Date(), + nextGeneration = () => crypto.randomUUID(), +}) { + if (!CHAIN_PATTERN.test(String(chain || '')) + || !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535 + || !MARK_PATTERN.test(String(tproxyMark || ''))) { + throw new Error('Некорректная конфигурация device policy'); + } + const epoch = nextGeneration(); + let activeSlot = 'A'; + let activeSignature = JSON.stringify([]); + let generation = epoch; + let appliedDevices = []; + let observedAt = now().toISOString(); + let queue = Promise.resolve(); + + function execute(command, args, input) { + const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input }); + if (result.error || result.status !== 0) throw commandError(command, result); + } + + function snapshot(changed = false) { + return { + epoch, + generation, + fingerprint: fingerprint(appliedDevices), + observedAt, + appliedIds: appliedDevices.map(({ id }) => id), + changed, + }; + } + + function performApply(value) { + const devices = normalizeDirectDevices(value); + const signature = JSON.stringify(devices); + if (signature === activeSignature) return snapshot(false); + const nextSlot = activeSlot === 'A' ? 'B' : 'A'; + execute('iptables-restore', ['-w', '1', '--noflush'], buildDevicePolicyRestore({ + devices, + chain, + slot: nextSlot, + tproxyPort, + tproxyMark, + })); + execute('iptables', [ + '-w', '1', '-t', 'mangle', '-R', chain, '1', '-j', childChain(chain, nextSlot), + ]); + activeSlot = nextSlot; + activeSignature = signature; + appliedDevices = devices; + generation = nextGeneration(); + observedAt = now().toISOString(); + return snapshot(true); + } + + function apply(devices) { + const result = queue.then(() => performApply(devices)); + queue = result.catch(() => {}); + return result; + } + + return { apply, snapshot: () => snapshot(false) }; +} diff --git a/src/shared/errors.js b/src/shared/errors.js index c2a0d6d..c5873fe 100644 --- a/src/shared/errors.js +++ b/src/shared/errors.js @@ -11,6 +11,9 @@ export const ERROR_DEFINITIONS = Object.freeze({ STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true }, SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false }, DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false }, + DEVICE_POLICY_REQUIRES_PIN: { status: 409, message: 'Сначала закрепите устройство.', retryable: false }, + DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true }, + DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true }, CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false }, PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true }, OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true }, diff --git a/src/shared/versions.js b/src/shared/versions.js index b2cd4ef..617a55a 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.11.0', - gatewayClient: '0.11.0', - gatewayBackend: '0.11.0', + macClient: '0.12.0', + gatewayClient: '0.12.0', + gatewayBackend: '0.12.0', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index 95d71e2..308e7cd 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -86,6 +86,10 @@ export const api = { method: 'PUT', body: JSON.stringify({ ...patch, expectedRevision }), }), + setPolicy: (id, mode, expectedRevision) => request(`/api/devices/${id}/policy`, { + method: 'PUT', + body: JSON.stringify({ mode, expectedRevision }), + }), }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index bfc3fd8..3d6e619 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -130,6 +130,37 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { setEditingId(''); } + async function updatePolicy(device, mode) { + setSavingId(device.id); + try { + let next; + try { + next = await api.devices.setPolicy(device.id, mode, snapshot.revision); + } catch (requestError) { + if (requestError.code !== 'STATE_CONFLICT') throw requestError; + const latest = await api.devices.list(); + setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current); + const latestDevice = latest.devices.find((candidate) => candidate.id === device.id); + if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError; + next = await api.devices.setPolicy(device.id, mode, latest.revision); + } + setSnapshot((current) => !current || next.revision >= current.revision ? next : current); + setError(null); + } catch (requestError) { + if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') { + try { + const latest = await api.devices.list(); + setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current); + } catch { + // Keep the policy error as the actionable result. + } + } + setError(requestError); + } finally { + setSavingId(''); + } + } + return (