feat(netflow): локальные GeoLite2-базы для стран и ASN потоков
Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 4m31s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s

Lookup страны/ASN при ingest теперь идёт сначала по локальным mmdb
MaxMind GeoLite2 (Country + ASN, зеркало P3TERX, без ключей), с
мгновенным синхронным ответом для IPv4/IPv6; RIPEstat остаётся
fallback до первой загрузки баз и при промахе.

- backend/src/services/traffic-flow-geoip.ts: ридеры maxmind, фасад
  resolveFlowIp (geoip-first → RIPE-кэш), статус ридеров
- geoip-update-collector.ts: conditional GET по ETag, валидация пробоем
  8.8.8.8 (US / AS15169), атомарная подмена с .prev-откатом
- джоба планировщика geoip_update (по умолчанию раз в 7 дней),
  настройки geoip_settings + миграция 0004, API /api/geoip
  (GET/PUT/update), контракты @mmapp/contracts/geoip
- engine/analytics/map-hops переведены на resolveFlowIp
- секция «GeoIP-базы» в настройках NetFlow, метки джобы на странице
  сбора данных, тесты test:geoip
This commit is contained in:
Denozordec
2026-09-09 23:33:31 +07:00
parent d39e3454aa
commit a80caf5676
25 changed files with 1114 additions and 23 deletions
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import type { GeoipStatusDto } from "@mmapp/contracts/geoip"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
@@ -17,6 +18,7 @@ import {
purgeTrafficFlowData,
putTrafficFlowSettings,
} from "@/shared/api/traffic-flow"
import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
@@ -29,6 +31,127 @@ const HOST_STEPS = [
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
function fmtDate(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString("ru-RU") : "—"
}
function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) {
const [status, setStatus] = useState<GeoipStatusDto | null>(null)
const [busy, setBusy] = useState(false)
const [updating, setUpdating] = useState(false)
const [autoOn, setAutoOn] = useState(true)
const [intervalHours, setIntervalHours] = useState("168")
const load = useCallback(async () => {
const s = await getGeoipStatus(backendUrl)
setStatus(s)
setAutoOn(s.settings.enabled)
setIntervalHours(String(Math.round(s.settings.updateIntervalSec / 3600)))
}, [backendUrl])
useEffect(() => {
void load().catch((e: unknown) => {
toast.error(e instanceof Error ? e.message : "Не удалось загрузить GeoIP")
})
}, [load])
async function handleSave() {
setBusy(true)
try {
const hours = Math.min(720, Math.max(6, Number.parseInt(intervalHours, 10) || 168))
const res = await putGeoipSettings(backendUrl, {
enabled: autoOn,
updateIntervalSec: hours * 3600,
})
setStatus(res.status)
toast.success("Настройки GeoIP сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setBusy(false)
}
}
async function handleUpdateNow() {
setUpdating(true)
try {
const res = await runGeoipUpdateNow(backendUrl)
if (res.ok || res.snapshot.downloaded > 0 || res.snapshot.skippedUnchanged > 0) {
toast.success(
res.snapshot.downloaded > 0
? `Скачано баз: ${res.snapshot.downloaded} (${(res.snapshot.bytes / 1024 / 1024).toFixed(1)} МБ)`
: "Базы актуальны, скачивание не требуется",
)
} else {
toast.error(res.snapshot.errors.join("; ") || "Обновление не выполнено")
}
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось обновить базы")
} finally {
setUpdating(false)
}
}
return (
<OpsPanel
title="GeoIP-базы (GeoLite2)"
description="Локальные mmdb MaxMind GeoLite2 с зеркала P3TERX: страна и ASN каждого потока при ingest — мгновенно, включая IPv6, без лимитов RIPEstat."
headerRight={
<div className="flex items-center gap-2">
<Badge variant={status?.countryLoaded ? "success" : "secondary"}>
country {status?.countryLoaded ? "ok" : "нет"}
</Badge>
<Badge variant={status?.asnLoaded ? "success" : "secondary"}>
asn {status?.asnLoaded ? "ok" : "нет"}
</Badge>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3">
<FormToggle checked={autoOn} onChange={setAutoOn} />
<span className="text-sm">Автообновление</span>
</div>
<FormField label="Интервал проверки (часов)" hint="Upstream обновляется еженедельно; минимум 6 ч">
<Input
className="font-mono"
value={intervalHours}
onChange={(e) => setIntervalHours(e.target.value)}
inputMode="numeric"
disabled={!autoOn}
/>
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Сборка Country: {fmtDate(status?.settings.countryBuildAt)} · ASN: {fmtDate(status?.settings.asnBuildAt)}
{" · "}последняя проверка: {fmtDate(status?.settings.lastCheckAt)}
{status?.settings.lastError ? ` · ошибка: ${status.settings.lastError}` : ""}
</p>
<p className="text-xs text-muted-foreground">
Каталог: <span className="font-mono">{status?.dir || "storage/geoip"}</span>. До загрузки баз
и при промахе lookup страна/ASN берутся из RIPEstat, как раньше.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || updating} onClick={() => { void handleSave() }}>
Сохранить GeoIP
</Button>
<Button size="sm" variant="outline" disabled={busy || updating} onClick={() => { void handleUpdateNow() }}>
<DownloadIcon className={updating ? "size-4 animate-spin" : "size-4"} />
{updating ? "Обновление…" : "Обновить сейчас"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.
</p>
</OpsPanel>
)
}
function NetflowSettingsPanel({
backendUrl,
enabled,
@@ -276,6 +399,8 @@ function NetflowSettingsPanel({
</div>
</OpsPanel>
<GeoipSettingsSection backendUrl={backendUrl} />
<CodeExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}