From 7e15cc199fe0a1ddf97c2cf3f181abb13ea4d815 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 31 Aug 2026 01:07:59 +0300 Subject: [PATCH] Default new devices to Direct routing --- README.md | 2 + src/server/services/deviceInventoryService.ts | 16 ++ src/shared/versions.ts | 6 +- src/web/features/devices/DevicesPanel.tsx | 10 +- src/web/features/devices/deviceSnapshot.ts | 2 + src/web/styles/features/devices.css | 25 +++ src/web/utils/format.ts | 8 + test/server/device-inventory.test.js | 187 +++++++++++++++++- test/web/device-inventory-contract.test.js | 15 ++ test/web/devices-feature-contract.test.js | 3 + test/web/style-boundaries.test.js | 24 +-- 11 files changed, 276 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 157d276..5d53313 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ http://АДРЕС-GATEWAY:3456 Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters. +Устройство, впервые замеченное после обновления Gateway, по умолчанию идёт `Напрямую` и первые семь дней отмечается `NEW`; исчезновение метки маршрут не меняет. Уже известные при обновлении устройства сохраняют текущий VPN, даже если метка ещё видна по их `firstSeenAt`. VPN разрешается существующей последней иконкой маршрута. Если новый device пока распознан неоднозначно, Harbor сохраняет Direct-намерение, временно оставляет фактический VPN и применяет Direct после однозначного наблюдения. + У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Запись автоматически удаляется после 30 дней без подтверждённого контакта независимо от имени, закрепления или фонового положения; временная ошибка чтения сети этот срок не продвигает. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index a6525c0..1531b41 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -1290,10 +1290,23 @@ export function createDeviceInventoryService({ const nextState = store.update((stored) => { const state = migrateDeviceInventoryState(stored); const byMac = new Map(state.devices.map((device) => [device.mac, device])); + let policyByMac = state.policy.byMac; for (const observation of observations) { const mac = normalizeMac(observation.mac); if (!mac) continue; const previous = byMac.get(mac); + if (!previous) { + const retained = policyByMac[mac]; + if (policyByMac === state.policy.byMac) policyByMac = { ...policyByMac }; + policyByMac[mac] = { + desired: 'direct', + applied: retained?.applied || 'vpn', + status: 'applying', + appliedAt: retained?.appliedAt || null, + error: null, + operationId: crypto.randomUUID(), + }; + } const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt; const lastSeenAt = observation.active || !previous ? observationTime @@ -1532,6 +1545,9 @@ export function createDeviceInventoryService({ revision: state.revision + 1, lastObservedAt: observedAt, lastError: typeof result.error === 'string' ? result.error : null, + policy: policyByMac === state.policy.byMac + ? state.policy + : { ...state.policy, lastError: null, byMac: policyByMac }, traffic, devices, }; diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 949de41..e799b44 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.31.2', - gatewayClient: '0.32.2', - gatewayBackend: '0.32.2', + macClient: '0.32.0', + gatewayClient: '0.33.0', + gatewayBackend: '0.33.0', }); export interface ParsedVersion { diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index 04b5336..0625b07 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -14,6 +14,7 @@ import { byteString, formatByteString, formatLastSeen, + isNewDevice, positiveByteDelta, stabilizeDevicesByTraffic, } from '../../utils/format.js'; @@ -444,6 +445,7 @@ 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 newDevice = isNewDevice(device.firstSeenAt); const editing = editingId === device.id; const saving = savingId === device.id; const seen = formatLastSeen(device.lastSeenAt); @@ -493,7 +495,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { : device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct' ? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства' : displayPolicy === 'direct' - ? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway' + ? newDevice + ? 'Новое устройство идёт напрямую. Нажмите, чтобы разрешить VPN через правила Gateway' + : 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway' : 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую'; return
startEditing(device)} >{title}} {!hasName && !editing && {title}} + {!editing && newDevice && + + Новое устройство + } {!editing && {device.ip &&