diff --git a/.zcode/plans/plan-sess_36e77033-b276-4928-b2d4-f9d734df4686.md b/.zcode/plans/plan-sess_36e77033-b276-4928-b2d4-f9d734df4686.md new file mode 100644 index 0000000..e49f887 --- /dev/null +++ b/.zcode/plans/plan-sess_36e77033-b276-4928-b2d4-f9d734df4686.md @@ -0,0 +1,63 @@ +# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN + +## Контекст + +Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов. + +Решения (подтверждены): +- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной. +- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата). +- City-базу не качаем (lat/lng фронтенд не использует). + +## Изменения + +### 1. Зависимость +- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок. + +### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts` +(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах) +- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`. +- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают. +- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером. +- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей. +- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок. + +### 3. Коллектор `backend/src/services/geoip-update-collector.ts` +`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`: +1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое). +2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle). +3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169). +4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся). +5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error). + +### 4. Планировщик (`backend/src/services/scheduler.ts`) +- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке. + +### 5. Схема и миграция +- `backend/src/db/schema.ts`: singleton `geoip_settings` — `enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`. +- Миграция: `npm run db:generate` → файл в `backend/drizzle/`. + +### 6. API + контракты +- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта). +- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`: + - `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка); + - `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`; + - `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах). + +### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback) +- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached` → `resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение. +- Остальные вызовы `lookupRipeCached` → `resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258–271). +- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть. + +### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование) +- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы. + +### 9. Хаускипинг, тесты, проверки +- `backend/.gitignore`: `storage/geoip/`. +- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером. +- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать. +- Коммит: `feat(netflow): ` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru. +- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume. + +## Что это даёт +Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз. \ No newline at end of file diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 51e5ae1..2baafef 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -1216,6 +1216,9 @@ export default function NetworkMapPage() { // ── Interaction ───────────────────────────────────────────────────────────── const [selected, setSelected] = useState(null) const [selectedService, setSelectedService] = useState(null) + const liveSelectedService = selectedService + ? (mapServices.find((s) => s.id === selectedService.id) ?? selectedService) + : null const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null) const [selWanIdx, setSelWanIdx] = useState(null) const [hoveredId, setHoveredId] = useState(null) @@ -2702,16 +2705,16 @@ export default function NetworkMapPage() { })()} - ) : selectedService ? ( + ) : liveSelectedService ? ( <>
- +
-

{selectedService.label}

+

{liveSelectedService.label}

- Конечный сервис · {selectedService.category} + Конечный сервис · {liveSelectedService.category}