From 3566f4bc0b0e09d5a470a9c92dfb727097e49e9b Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 11 Aug 2026 12:39:27 +0300 Subject: [PATCH] Show device identity details and resolve hostnames --- README.md | 2 +- src/server/services/deviceInventoryService.ts | 64 ++++++++++- src/shared/versions.ts | 6 +- src/web/features/devices/DevicesPanel.tsx | 52 +++++---- src/web/styles/features/devices.css | 106 ++++++++++++++---- src/web/styles/themes.css | 3 +- test/server/device-inventory.test.js | 37 ++++++ test/web/device-inventory-contract.test.js | 32 +++--- test/web/style-boundaries.test.js | 34 +++--- test/web/ui-foundations-contract.test.js | 2 +- 10 files changed, 256 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 41426be..049b203 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. +Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index fb31621..b2dc8b5 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { lookupService } from 'node:dns/promises'; import fs from 'node:fs'; import net from 'node:net'; import { HarborError } from '../../shared/errors.js'; @@ -163,6 +164,9 @@ const ONLINE_MS = 2 * 60 * 1000; const RECENT_MS = 24 * 60 * 60 * 1000; const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const TRAFFIC_HISTORY_LIMIT = 120; +const HOSTNAME_LOOKUP_LIMIT = 8; +const HOSTNAME_LOOKUP_TIMEOUT_MS = 800; +const HOSTNAME_RETRY_MS = 5 * 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}$/; @@ -247,6 +251,31 @@ const validTimestamp = (value: unknown): value is string => ( typeof value === 'string' && Number.isFinite(Date.parse(value)) ); +function normalizeHostname(value: unknown, ip: string) { + const hostname = typeof value === 'string' ? value.trim().replace(/\.$/, '') : ''; + return hostname && hostname !== ip && /^[a-z0-9_](?:[a-z0-9_.-]{0,251}[a-z0-9_])?$/i.test(hostname) + ? hostname + : null; +} + +async function resolveDeviceHostname(ip: string) { + let timer: ReturnType | undefined; + try { + const result = await Promise.race([ + lookupService(ip, 0), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), HOSTNAME_LOOKUP_TIMEOUT_MS); + timer.unref(); + }), + ]); + return normalizeHostname(result?.hostname, ip); + } catch { + return null; + } finally { + if (timer) clearTimeout(timer); + } +} + function normalizeInventoryDevice(value: unknown): InventoryDevice | null { const device = record(value); const mac = normalizeMac(device.mac); @@ -267,7 +296,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null { alias: typeof device.alias === 'string' ? device.alias : '', pinned: device.pinned === true, deprioritized: device.deprioritized === true && device.pinned !== true, - hostname: typeof device.hostname === 'string' ? device.hostname : null, + hostname: normalizeHostname(device.hostname, ip), manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null, mac, ip, @@ -643,6 +672,7 @@ export function createDeviceInventoryService({ observePolicy = null, applyPolicies = null, vendor = () => null, + resolveHostname = resolveDeviceHostname, now = () => new Date(), }: { store: InventoryStore; @@ -652,6 +682,7 @@ export function createDeviceInventoryService({ observePolicy?: (() => unknown | Promise) | null; applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise) | null; vendor?: (mac: string) => string | null; + resolveHostname?: (ip: string) => unknown | Promise; now?: () => Date; }) { let refreshPromise: Promise | null = null; @@ -660,6 +691,7 @@ export function createDeviceInventoryService({ const trafficCursorByMac = new Map(); let globalTrafficHistory: TrafficSample[] = []; let globalTrafficCursor: TrafficCursor | null = null; + const hostnameAttempts = new Map(); let domainTrafficSnapshot: Record = { epoch: null, observedAt: null, @@ -995,6 +1027,34 @@ export function createDeviceInventoryService({ identities.add(`${String(observation.ip)}|${observation.interface || ''}`); identitiesByMac.set(mac, identities); } + const previousByMac = new Map(migrateDeviceInventoryState(store.read()).devices + .map((device) => [device.mac, device])); + const hostnameCandidates = new Map(); + const hostnameKeys = new Set(); + const refreshTime = new Date(observedAt).getTime(); + for (const observation of observations) { + const key = `${observation.mac}|${observation.ip}`; + hostnameKeys.add(key); + const previous = previousByMac.get(observation.mac); + if (!observation.active || (identitiesByMac.get(observation.mac)?.size || 0) !== 1 + || (previous?.hostname && previous.ip === observation.ip) + || refreshTime - (hostnameAttempts.get(key) || 0) < HOSTNAME_RETRY_MS) continue; + hostnameCandidates.set(observation.mac, observation); + } + for (const key of hostnameAttempts.keys()) { + if (!hostnameKeys.has(key)) hostnameAttempts.delete(key); + } + // ponytail: resolve eight names per poll; add a queue only if large LANs need faster first-pass naming. + const hostnameByMac = new Map(); + await Promise.all([...hostnameCandidates].slice(0, HOSTNAME_LOOKUP_LIMIT).map(async ([mac, observation]) => { + hostnameAttempts.set(`${mac}|${observation.ip}`, refreshTime); + try { + const hostname = normalizeHostname(await resolveHostname(observation.ip), observation.ip); + if (hostname) hostnameByMac.set(mac, hostname); + } catch { + // Reverse lookup is best-effort and must not make inventory refresh stale. + } + })); return serializePolicy(async () => { if (typeof domainTrafficResult?.transportError === 'string') { domainTrafficSnapshot = { @@ -1021,7 +1081,7 @@ export function createDeviceInventoryService({ alias: previous?.alias || '', pinned: previous?.pinned === true, deprioritized: previous?.deprioritized === true && previous?.pinned !== true, - hostname: previous?.hostname || null, + hostname: hostnameByMac.get(mac) || previous?.hostname || null, manufacturer: previous?.manufacturer || vendor(mac), mac, ip: replaceAddress ? observation.ip : previous.ip, diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 0e320ed..b1636f8 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.24.1', - gatewayClient: '0.25.1', - gatewayBackend: '0.25.0', + macClient: '0.24.2', + gatewayClient: '0.25.2', + gatewayBackend: '0.25.1', }); export interface ParsedVersion { diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index fb3ac1a..e37880a 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -35,6 +35,8 @@ interface PinCollapse { result: 'pending' | 'saved' | 'failed'; } +type DeviceCopyField = 'IP' | 'MAC' | 'Hostname'; + function requestMessage(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const message: unknown = Reflect.get(value, 'message'); @@ -77,7 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const [alias, setAlias] = useState(''); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear'); - const [copyFeedback, setCopyFeedback] = useState>({}); + const [copyFeedback, setCopyFeedback] = useState>({}); const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null); const [pencilAnimationId, setPencilAnimationId] = useState(''); const [trafficDeltas, setTrafficDeltas] = useState>({}); @@ -189,15 +191,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { setEditingId((current) => current === device.id ? '' : current); } - async function copyDeviceIp(device: Device) { - if (!device.ip) return; + async function copyDeviceValue(device: Device, field: DeviceCopyField, value: string) { const activeTimer = copyTimers.current.get(device.id); if (activeTimer) clearTimeout(activeTimer); const attempt = {}; copyAttempts.current.set(device.id, attempt); let feedback: { failed: boolean }; try { - await copyText(device.ip); + await copyText(value); feedback = { failed: false }; } catch { feedback = { failed: true }; @@ -205,11 +206,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { if (copyAttempts.current.get(device.id) !== attempt) return; const announcement = { id: device.id, - message: feedback.failed ? `Не удалось скопировать IP ${device.ip}` : `IP ${device.ip} скопирован`, + message: feedback.failed ? `Не удалось скопировать ${field} ${value}` : `${field} ${value} скопирован`, }; const pendingTimer = copyTimers.current.get(device.id); if (pendingTimer) clearTimeout(pendingTimer); - setCopyFeedback((current) => ({ ...current, [device.id]: feedback })); + setCopyFeedback((current) => ({ ...current, [device.id]: { ...feedback, field } })); setCopyAnnouncement(announcement); copyTimers.current.set(device.id, setTimeout(() => { setCopyFeedback((current) => { @@ -387,7 +388,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const groupStart = group !== previousGroup; const hasName = Boolean(device.alias || device.hostname); const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; - const identityTooltipId = `device-identity-${device.id}`; const editing = editingId === device.id; const saving = savingId === device.id; const seen = formatLastSeen(device.lastSeenAt); @@ -400,7 +400,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const hasProxyTraffic = proxyTotal > 0n; const trafficDelta = trafficDeltas[device.id] || {}; const feedback = copyFeedback[device.id]; - const copied = Boolean(feedback); const policyBusy = device.policyStatus === 'applying'; const policyFailed = device.policyStatus === 'failed'; const policyPending = device.policyStatus === 'pending'; @@ -453,7 +452,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { }
-

+

{editing ? ( setAlias(event.target.value)} @@ -475,18 +476,29 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`} type="button" aria-label={`Изменить название ${title}`} - aria-describedby={identityTooltipId} onClick={() => startEditing(device)} >{title}} - {(hasName || (editing && Boolean(alias))) && device.ip && } - {device.ip ? : !hasName && !editing && Неизвестное устройство} - Hostname: {device.hostname || '—'} · MAC: {device.mac} + {!hasName && !editing && {title}} + {!editing && + {device.ip && } + + {device.hostname && } + }

{!hasName && !editing &&