From 158aaadd2399e227dd7576bca700cd8efb45b64c Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Thu, 6 Aug 2026 10:17:33 +0300 Subject: [PATCH] Add Gateway device inventory panel --- Dockerfile | 2 +- Dockerfile.runtime-base | 2 +- README.md | 6 + src/server/adapters/neighbors.js | 50 +++++ src/server/config.js | 1 + src/server/dataplane.js | 4 + src/server/dataplaneClient.js | 1 + src/server/index.js | 40 ++++ src/server/services/deviceInventoryService.js | 170 ++++++++++++++++ src/shared/errors.js | 1 + src/shared/versions.js | 6 +- src/web/api.js | 7 + src/web/components/ClientOverviewPage.jsx | 56 ++++++ src/web/components/DevicesPanel.jsx | 173 ++++++++++++++++ src/web/styles.css | 188 ++++++++++++++++++ test/server/dataplane-client.test.js | 4 + test/server/device-inventory.test.js | 81 ++++++++ test/web/device-inventory-contract.test.js | 24 +++ 18 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 src/server/adapters/neighbors.js create mode 100644 src/server/services/deviceInventoryService.js create mode 100644 src/web/components/DevicesPanel.jsx create mode 100644 test/server/device-inventory.test.js create mode 100644 test/web/device-inventory-contract.test.js diff --git a/Dockerfile b/Dockerfile index 0beb5b6..99d6dd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY dist /app/dist RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \ apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \ + && apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data nodejs dumb-init \ && rm -rf /var/lib/apt/lists/*; \ else \ command -v dumb-init >/dev/null \ diff --git a/Dockerfile.runtime-base b/Dockerfile.runtime-base index 1f96208..a2f59af 100644 --- a/Dockerfile.runtime-base +++ b/Dockerfile.runtime-base @@ -32,7 +32,7 @@ RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \ -o Acquire::http::Timeout=20 \ -o Acquire::https::Timeout=20 \ -o Acquire::ForceIPv4=true \ - install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs npm dumb-init \ + install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data nodejs npm dumb-init \ && rm -rf /var/lib/apt/lists/* RUN set -eux; \ diff --git a/README.md b/README.md index 16fd3d1..f9aa928 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ http://АДРЕС-GATEWAY:3456 Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей. +### Устройства Gateway + +После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в минуту читает локальную таблицу соседей, показывает IP, MAC, интерфейс, последний контакт и производителя из локальной OUI-базы. Устройство можно переименовать и закрепить; эти настройки сохраняются в volume Gateway. + +Список приблизительный: private/randomized MAC определяется как менее надёжная identity, а устройство появляется только после сетевого контакта с Gateway. Внешние сервисы распознавания производителя не используются. Учёт трафика по устройствам в этот экран пока не входит. + ## Установка Harbor Connect на macOS ### 1. Запустите Docker Desktop diff --git a/src/server/adapters/neighbors.js b/src/server/adapters/neighbors.js new file mode 100644 index 0000000..f9a18f5 --- /dev/null +++ b/src/server/adapters/neighbors.js @@ -0,0 +1,50 @@ +import { spawnSync } from 'node:child_process'; + +const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']); +const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']); +const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i; + +export function parseNeighborSnapshot(value, observedAt = new Date().toISOString()) { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const states = (Array.isArray(entry?.state) ? entry.state : [entry?.state]) + .filter(Boolean) + .map((state) => String(state).toUpperCase()); + const mac = String(entry?.lladdr || '').toLowerCase(); + if (!entry?.dst || !entry?.dev || !MAC_PATTERN.test(mac) || states.some((state) => IGNORED_STATES.has(state))) { + return []; + } + return [{ + ip: String(entry.dst), + mac, + interface: String(entry.dev), + active: states.some((state) => ACTIVE_STATES.has(state)), + observedAt, + source: 'neighbor', + }]; + }); +} + +export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) { + const observedAt = now().toISOString(); + const result = run('ip', ['-j', 'neigh', 'show'], { + encoding: 'utf8', + timeout: 1500, + }); + if (result.error || result.status !== 0) { + return { + observedAt, + observations: [], + error: result.error?.message || String(result.stderr || 'ip neigh завершился с ошибкой').trim(), + }; + } + try { + return { + observedAt, + observations: parseNeighborSnapshot(JSON.parse(result.stdout || '[]'), observedAt), + error: null, + }; + } catch (error) { + return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${error.message}` }; + } +} diff --git a/src/server/config.js b/src/server/config.js index b35e21a..b54dcf2 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -24,6 +24,7 @@ export const settings = { process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", statePath: path.join(dataDir, "state.json"), + deviceStatePath: path.join(dataDir, "devices.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), sharedProxyHost: process.env.SHARED_PROXY_HOST || "", hostNetworkStatePath: diff --git a/src/server/dataplane.js b/src/server/dataplane.js index aa1bf5e..4c6193d 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -4,6 +4,7 @@ import path from 'node:path'; import { settings } from './config.js'; import { createSingboxRuntime } from './singboxRuntime.js'; import { buildVersionInfo } from './version.js'; +import { readNeighborSnapshot } from './adapters/neighbors.js'; const socketPath = settings.dataplaneSocket; const runtime = createSingboxRuntime({ @@ -29,6 +30,9 @@ const server = http.createServer(async (req, res) => { ready, }); } + if (req.method === 'GET' && req.url === '/devices') { + return sendJson(res, 200, readNeighborSnapshot()); + } 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 fddce30..48c0d56 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.js @@ -43,6 +43,7 @@ export function createDataplaneClient(socketPath, send = request) { get running() { return Boolean(current.running); }, get startedAt() { return current.startedAt || null; }, refresh: () => update('/status', 'GET'), + observeDevices: () => send(socketPath, '/devices', 'GET'), 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 ed6e0ee..982c387 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -4,6 +4,7 @@ import http from 'node:http'; import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { createDataplaneClient } from './dataplaneClient.js'; +import { readNeighborSnapshot } from './adapters/neighbors.js'; import { settings } from './config.js'; import { applyGatewayPreference, @@ -38,11 +39,13 @@ import { import { HarborError, normalizeHarborError } from '../shared/errors.js'; import { normalizeRouteRules } from '../shared/routingRules.js'; import { createJsonStore, createStateStore } from './services/stateStore.js'; +import { createDeviceInventoryService, createVendorLookup } from './services/deviceInventoryService.js'; import { buildGatewayVersionInfo, buildVersionInfo } from './version.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000; +const DEVICE_DISCOVERY_INTERVAL_MS = 60_000; const TERMINAL_SUBSCRIPTION_CODES = new Set([ 'SUBSCRIPTION_EXPIRED', 'SUBSCRIPTION_DISABLED', @@ -56,6 +59,10 @@ const subscriptionCacheStore = createJsonStore({ filePath: settings.subscriptionCachePath, defaultValue: null, }); +const deviceStore = createJsonStore({ + filePath: settings.deviceStatePath, + defaultValue: {}, +}); let cacheRecoveryLogged = false; function readSubscriptionCache() { @@ -86,10 +93,20 @@ const singboxRuntime = remoteDataplane gateway: settings.appMode === 'gateway', tproxyChain: settings.tproxyChain, }); +const deviceInventory = settings.appMode === 'gateway' + ? createDeviceInventoryService({ + store: deviceStore, + observe: remoteDataplane + ? () => singboxRuntime.observeDevices() + : () => readNeighborSnapshot(), + vendor: createVendorLookup(), + }) + : null; let subscriptionRefreshPromise = null; let subscriptionRefreshTimer = null; let gatewayDiscoveryPromise = null; let gatewayDiscoveryTimer = null; +let deviceDiscoveryTimer = null; let gatewayAutoState = createGatewayAutoState(); let controlOperation = Promise.resolve(); let operationState = stateStore.recovery ? { @@ -595,6 +612,18 @@ async function handleApi(req, res) { } const requestUrl = new URL(req.url, `http://localhost:${settings.port}`); + if (requestUrl.pathname === '/api/devices') { + if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND'); + return sendJson(res, 200, deviceInventory.snapshot()); + } + + const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/); + 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)); + } + if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') { const state = stateStore.read(); return sendJson(res, 200, buildGatewayPresence({ @@ -768,6 +797,7 @@ const server = http.createServer(async (req, res) => { async function shutdown() { clearInterval(subscriptionRefreshTimer); clearInterval(gatewayDiscoveryTimer); + clearInterval(deviceDiscoveryTimer); await serializeControl(() => singboxRuntime.shutdown()); process.exit(0); } @@ -810,3 +840,13 @@ gatewayDiscoveryTimer = setInterval(() => { .catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`)); }, GATEWAY_DISCOVERY_INTERVAL_MS); gatewayDiscoveryTimer.unref(); + +if (deviceInventory) { + deviceInventory.refresh() + .catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`)); + deviceDiscoveryTimer = setInterval(() => { + deviceInventory.refresh() + .catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`)); + }, DEVICE_DISCOVERY_INTERVAL_MS); + deviceDiscoveryTimer.unref(); +} diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js new file mode 100644 index 0000000..dc6ce0d --- /dev/null +++ b/src/server/services/deviceInventoryService.js @@ -0,0 +1,170 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { HarborError } from '../../shared/errors.js'; + +const SCHEMA_VERSION = 1; +const ONLINE_MS = 2 * 60 * 1000; +const RECENT_MS = 24 * 60 * 60 * 1000; +const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +const DEFAULT_STATE = { + schemaVersion: SCHEMA_VERSION, + revision: 0, + lastObservedAt: null, + lastError: null, + devices: [], +}; + +const normalizeMac = (value) => String(value || '').trim().toLowerCase(); +const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`; +const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0; + +export function parseOuiVendors(text) { + const vendors = new Map(); + for (const line of String(text || '').split(/\r?\n/)) { + const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i); + if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim()); + } + return vendors; +} + +export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') { + let vendors; + return (mac) => { + if (!mac || isPrivateMac(mac)) return null; + if (!vendors) { + try { + vendors = parseOuiVendors(fs.readFileSync(filePath, 'utf8')); + } catch { + vendors = new Map(); + } + } + return vendors.get(mac.replaceAll(':', '').slice(0, 6)) || null; + }; +} + +function migrate(value) { + const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0; + if (version < 0 || version > SCHEMA_VERSION) { + throw new Error(`Unsupported device inventory schemaVersion: ${version}`); + } + return { + ...DEFAULT_STATE, + ...state, + schemaVersion: SCHEMA_VERSION, + revision: Number.isSafeInteger(state.revision) ? state.revision : 0, + devices: Array.isArray(state.devices) ? state.devices : [], + }; +} + +function deviceStatus(lastSeenAt, now) { + const age = now.getTime() - new Date(lastSeenAt).getTime(); + if (age <= ONLINE_MS) return 'online'; + if (age <= RECENT_MS) return 'recent'; + return 'offline'; +} + +export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) { + function snapshot() { + const state = migrate(store.read()); + const current = now(); + const rank = { online: 0, recent: 1, offline: 2 }; + const devices = state.devices.map((device) => ({ + ...device, + status: deviceStatus(device.lastSeenAt, current), + })).sort((left, right) => ( + Number(right.pinned) - Number(left.pinned) + || rank[left.status] - rank[right.status] + || String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)) + )); + return { + revision: state.revision, + source: { + kind: 'neighbor', + lastObservedAt: state.lastObservedAt, + error: state.lastError, + }, + devices, + }; + } + + async function refresh() { + let result; + try { + result = await observe(); + } catch (error) { + result = { observedAt: now().toISOString(), observations: [], error: error.message || String(error) }; + } + const observedAt = result?.observedAt || now().toISOString(); + const observations = Array.isArray(result?.observations) ? result.observations : []; + store.update((stored) => { + const state = migrate(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: 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 + )); + return { + ...state, + revision: state.revision + 1, + lastObservedAt: observedAt, + lastError: result?.error || null, + devices, + }; + }); + return snapshot(); + } + + function update(id, patch, expectedRevision) { + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { + throw new HarborError('REQUEST_INVALID'); + } + const aliasProvided = Object.hasOwn(patch, 'alias'); + const pinProvided = Object.hasOwn(patch, 'pinned'); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 + || (!aliasProvided && !pinProvided) + || (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64)) + || (pinProvided && typeof patch.pinned !== 'boolean')) { + throw new HarborError('REQUEST_INVALID'); + } + store.update((stored) => { + const state = migrate(stored); + 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 devices = [...state.devices]; + devices[index] = { + ...devices[index], + ...(aliasProvided ? { alias: patch.alias.trim() } : {}), + ...(pinProvided ? { pinned: patch.pinned } : {}), + }; + return { ...state, revision: state.revision + 1, devices }; + }); + return snapshot(); + } + + return { snapshot, refresh, update }; +} diff --git a/src/shared/errors.js b/src/shared/errors.js index fc1047d..c2a0d6d 100644 --- a/src/shared/errors.js +++ b/src/shared/errors.js @@ -10,6 +10,7 @@ export const ERROR_DEFINITIONS = Object.freeze({ PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true }, STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true }, SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false }, + DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false }, 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 83beeb6..8ef6ba0 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.8.12', - gatewayClient: '0.8.10', - gatewayBackend: '0.8.1', + macClient: '0.9.0', + gatewayClient: '0.9.0', + gatewayBackend: '0.9.0', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index baeed36..5fa1521 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -79,6 +79,13 @@ export const api = { body: JSON.stringify({ rules, expectedRulesRevision }), }), }, + devices: { + list: () => request('/api/devices'), + update: (id, patch, expectedRevision) => request(`/api/devices/${id}`, { + method: 'PUT', + body: JSON.stringify({ ...patch, expectedRevision }), + }), + }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }), diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index a509032..b9b0676 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -15,6 +15,7 @@ import { formatBytes } from '../utils/format.js'; import { instructionBlocks } from '../instructions.js'; import { operationBlocked } from '../state/operations.js'; import { ConfirmationPopup } from './ConfirmationPopup.jsx'; +import { DevicesPanel } from './DevicesPanel.jsx'; import { ServerPicker } from './ServerPicker.jsx'; import { ERROR_DEFINITIONS } from '../../shared/errors.js'; import { canAppendRouteRule } from '../../shared/routingRules.js'; @@ -640,6 +641,7 @@ export function ClientOverviewPage({ const [serversLeaving, setServersLeaving] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false); const [localRulesOpen, setLocalRulesOpen] = useState(false); + const [devicesOpen, setDevicesOpen] = useState(false); const [localRulesDraft, setLocalRulesDraft] = useState([]); const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0); const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false); @@ -655,6 +657,9 @@ export function ClientOverviewPage({ const localRulesPanelRef = useRef(null); const localRulesToggleRef = useRef(null); const localRulesCloseRef = useRef(null); + const devicesPanelRef = useRef(null); + const devicesToggleRef = useRef(null); + const devicesCloseRef = useRef(null); const localRulesBaselineRef = useRef('[]'); const previousHasSubscriptionRef = useRef(hasSubscription); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; @@ -849,6 +854,28 @@ export function ClientOverviewPage({ }; }, [instructionsOpen]); + useEffect(() => { + if (!devicesOpen) return undefined; + const frame = requestAnimationFrame(() => devicesCloseRef.current?.focus()); + const closeDevices = (event) => { + if (event.type === 'keydown' && event.key !== 'Escape') return; + if (event.type !== 'keydown' && ( + devicesPanelRef.current?.contains(event.target) || devicesToggleRef.current?.contains(event.target) + )) return; + setDevicesOpen(false); + }; + document.addEventListener('pointerdown', closeDevices); + document.addEventListener('keydown', closeDevices); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('pointerdown', closeDevices); + document.removeEventListener('keydown', closeDevices); + requestAnimationFrame(() => { + if (devicesPanelRef.current?.contains(document.activeElement)) devicesToggleRef.current?.focus(); + }); + }; + }, [devicesOpen]); + useEffect(() => { if (!localRulesOpen) return undefined; const frame = requestAnimationFrame(() => localRulesCloseRef.current?.focus()); @@ -993,6 +1020,7 @@ export function ClientOverviewPage({ function openLocalRules() { const rules = state?.route?.localRules || []; setInstructionsOpen(false); + setDevicesOpen(false); localRulesBaselineRef.current = localRulesSignature(rules); setLocalRulesDraft(rules.map(createLocalRuleDraft)); setLocalRulesRevision(state?.route?.localRulesRevision || 0); @@ -1078,6 +1106,7 @@ export function ClientOverviewPage({ aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'} onClick={() => { if (localRulesOpen && !requestCloseLocalRules()) return; + setDevicesOpen(false); setInstructionsOpen((open) => !open); }} > @@ -1087,6 +1116,26 @@ export function ClientOverviewPage({ Как использовать + {isGateway && } +
+ Gateway · {devices.length} +

Устройства

+
+

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

+
+
+ + {snapshot?.source?.error && ( +

+ Источник временно недоступен. Показаны последние сохранённые данные. +

+ )} + {error && ( +
+ {error.message} + +
+ )} + {status === 'loading' &&

Ищем устройства…

} + {status !== 'loading' && !devices.length && !error && ( +

Gateway пока не видит устройств.

+ )} + +
+ {devices.map((device) => { + const title = device.alias || device.hostname || device.manufacturer || device.ip; + const editing = editingId === device.id; + const saving = savingId === device.id; + return
+
+
+ {STATUS_LABELS[device.status]} +

{title}

+ {device.manufacturer && device.manufacturer !== title &&

{device.manufacturer}

} +
+ +
+ +
+
IP
{device.ip || '—'}
+
MAC
{device.mac || '—'}
+
Интерфейс
{device.interface || '—'}
+
Последний раз
+
Источник
{device.source} · {CONFIDENCE_LABELS[device.confidence]}
+
+ + {editing ? ( +
saveAlias(event, device)}> + + + +
+ ) : ( + + )} +
; + })} +
+ + + ); +} diff --git a/src/web/styles.css b/src/web/styles.css index ab777ef..66f8f52 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -715,6 +715,187 @@ p { transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1); } +.client-devices { + width: min(560px, 100vw); +} + +.client-devices-header { + margin-bottom: 28px; +} + +.client-devices-source, +.client-devices-error, +.client-devices-empty { + margin: 0 8px 20px; + color: var(--client-muted); + font-size: 10px; + line-height: 1.6; +} + +.client-devices-source { + color: oklch(0.62 0.1 72); +} + +.client-devices-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: oklch(0.62 0.13 28); +} + +.client-devices-error button, +.client-device-pin, +.client-device-rename, +.client-device-alias button { + padding: 0; + border: 0; + background: transparent; + color: var(--client-accent); + cursor: pointer; +} + +.client-devices-list { + display: grid; +} + +.client-device { + display: grid; + gap: 14px; + padding: 20px 8px; + border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent); +} + +.client-device-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) 32px; + gap: 12px; + align-items: start; +} + +.client-device-heading > div { + min-width: 0; +} + +.client-device-heading h3 { + overflow: hidden; + margin: 4px 0 0; + font-size: 15px; + letter-spacing: -0.03em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.client-device-heading p, +.client-device-status { + color: var(--client-muted); + font-size: 9px; +} + +.client-device-status::before { + width: 6px; + height: 6px; + display: inline-block; + margin-right: 7px; + border-radius: 50%; + background: currentColor; + content: ''; +} + +.client-device.is-online .client-device-status { + color: var(--client-accent); +} + +.client-device.is-offline .client-device-status { + opacity: 0.56; +} + +.client-device-pin { + width: 32px; + height: 32px; + color: var(--client-muted); + font-size: 16px; +} + +.client-device-pin[aria-pressed="true"] { + color: var(--client-accent); +} + +.client-device-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px 18px; + margin: 0; +} + +.client-device-meta div { + min-width: 0; +} + +.client-device-meta dt { + color: var(--client-muted); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.client-device-meta dd { + overflow: hidden; + margin: 3px 0 0; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.client-device-rename, +.client-device-alias button, +.client-devices-error button { + justify-self: start; + font-size: 9px; + font-weight: 700; +} + +.client-device-alias { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: end; + gap: 10px; +} + +.client-device-alias label { + display: grid; + gap: 5px; + color: var(--client-muted); + font-size: 8px; + text-transform: uppercase; +} + +.client-device-alias input { + min-width: 0; + padding: 8px 0; + border: 0; + border-bottom: 1px solid var(--client-border); + outline: 0; + background: transparent; + color: var(--client-text); + font-size: 10px; +} + +.client-device-alias input:focus { + border-color: var(--client-accent); +} + +.client-devices button:focus-visible { + outline: 2px solid var(--client-accent); + outline-offset: 2px; +} + +.client-devices button:disabled { + cursor: default; + opacity: 0.35; +} + .client-instructions-toggle:hover, .client-instructions-toggle:focus-visible, .client-local-rules-toggle:hover, @@ -3556,6 +3737,10 @@ p { padding: 40px 58px 60px 18px; } + .client-device-meta { + grid-template-columns: 1fr; + } + .client-local-rules-sheet { padding: 40px 58px 60px 18px; } @@ -3654,6 +3839,9 @@ p { .client-instruction-reveal, .client-instruction-summary > i::before, .client-instruction-summary > i::after, + .client-device, + .client-device-pin, + .client-device-rename, .client-subscription-edit, .client-subscription-edit::after, .client-subscription-submit, diff --git a/test/server/dataplane-client.test.js b/test/server/dataplane-client.test.js index d892a39..7cba74e 100644 --- a/test/server/dataplane-client.test.js +++ b/test/server/dataplane-client.test.js @@ -20,11 +20,15 @@ test('control uses the dataplane socket protocol', async () => { assert.equal(status.gatewayBackendVersion, '0.1.0'); assert.equal(status.singBoxVersion, '1.12.13'); await client.apply(); + const devices = await client.observeDevices(); + assert.equal(devices.running, true); + assert.equal(client.running, true); await client.restart(); assert.equal((await client.stop()).running, false); assert.deepEqual(requests, [ 'GET /status /run/dataplane.sock', 'POST /apply /run/dataplane.sock', + 'GET /devices /run/dataplane.sock', 'POST /restart /run/dataplane.sock', 'POST /stop /run/dataplane.sock', ]); diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js new file mode 100644 index 0000000..19c5ed0 --- /dev/null +++ b/test/server/device-inventory.test.js @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { parseNeighborSnapshot, readNeighborSnapshot } from '../../src/server/adapters/neighbors.js'; +import { + createDeviceInventoryService, + createVendorLookup, +} from '../../src/server/services/deviceInventoryService.js'; +import { createJsonStore } from '../../src/server/services/stateStore.js'; + +test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} }); + const ouiPath = path.join(directory, 'oui.txt'); + fs.writeFileSync(ouiPath, '00-11-22 (hex)\t\tExample Devices\n'); + const vendor = createVendorLookup(ouiPath); + assert.equal(vendor('00:11:22:33:44:55'), 'Example Devices'); + assert.equal(vendor('02:11:22:33:44:55'), null); + + let current = new Date('2026-07-01T10:00:00.000Z'); + let observation = { + observedAt: current.toISOString(), + observations: parseNeighborSnapshot([ + { dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] }, + { dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] }, + { dst: '192.168.50.11', dev: 'br0', state: ['INCOMPLETE'] }, + ], current.toISOString()), + error: null, + }; + const service = createDeviceInventoryService({ + store, + observe: async () => observation, + vendor, + now: () => current, + }); + + let snapshot = await service.refresh(); + assert.equal(snapshot.devices.length, 1); + assert.equal(snapshot.devices[0].manufacturer, 'Example Devices'); + assert.equal(snapshot.devices[0].status, 'online'); + assert.equal(snapshot.devices[0].confidence, 'high'); + + current = new Date('2026-07-01T10:05:00.000Z'); + observation = { + observedAt: current.toISOString(), + observations: parseNeighborSnapshot([ + { dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] }, + ], current.toISOString()), + error: null, + }; + const firstSeen = snapshot.devices[0].lastSeenAt; + snapshot = await service.refresh(); + assert.equal(snapshot.devices[0].lastSeenAt, firstSeen); + assert.equal(snapshot.devices[0].status, 'recent'); + + snapshot = service.update(snapshot.devices[0].id, { alias: 'Телевизор', pinned: true }, snapshot.revision); + const restarted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current }); + assert.deepEqual(restarted.snapshot().devices[0], snapshot.devices[0]); + assert.throws( + () => restarted.update(snapshot.devices[0].id, { pinned: false }, snapshot.revision - 1), + (error) => error.code === 'STATE_CONFLICT', + ); + + observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' }; + snapshot = await restarted.refresh(); + assert.equal(snapshot.devices.length, 1); + assert.equal(snapshot.source.error, 'source unavailable'); + + snapshot = restarted.update(snapshot.devices[0].id, { alias: '', pinned: false }, snapshot.revision); + current = new Date('2026-08-02T10:00:00.000Z'); + observation = { observedAt: current.toISOString(), observations: [], error: null }; + snapshot = await restarted.refresh(); + assert.equal(snapshot.devices.length, 0); + + const failed = readNeighborSnapshot(() => ({ status: 1, stderr: 'not available' }), () => current); + assert.deepEqual(failed.observations, []); + assert.match(failed.error, /not available/); +}); diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js new file mode 100644 index 0000000..6413376 --- /dev/null +++ b/test/web/device-inventory-contract.test.js @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8'); +const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8'); +const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8'); + +test('Gateway device inventory uses the existing accessible responsive drawer', () => { + assert.match(overview, /isGateway &&