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
423 lines
17 KiB
TypeScript
423 lines
17 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useState } from "react"
|
||
import { toast } from "sonner"
|
||
import { FormField, FormToggle } from "@/components/form-kit"
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||
import { Badge } from "@/components/reui/badge"
|
||
import { OpsPanel } from "@/components/ops-panel"
|
||
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,
|
||
getTrafficFlowSettings,
|
||
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"
|
||
|
||
const HOST_STEPS = [
|
||
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
|
||
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
|
||
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
|
||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
||
"Проверка: 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,
|
||
}: {
|
||
backendUrl: string
|
||
enabled: boolean
|
||
}) {
|
||
const [settings, setSettings] = useState<TrafficFlowSettingsDto | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [exportOpen, setExportOpen] = useState(false)
|
||
const [formats, setFormats] = useState<CodeExportFormat[]>([])
|
||
const [collectorIp, setCollectorIp] = useState("10.255.254.1")
|
||
const [flowPort, setFlowPort] = useState("4739")
|
||
const [wgPort, setWgPort] = useState("51821")
|
||
const [prefix, setPrefix] = useState("10.255.254.0/24")
|
||
const [endpoint, setEndpoint] = useState("")
|
||
const [retention, setRetention] = useState("24")
|
||
const [topN, setTopN] = useState("200")
|
||
const [shareOn, setShareOn] = useState(true)
|
||
const [sharePct, setSharePct] = useState("5")
|
||
const [ingestOn, setIngestOn] = useState(false)
|
||
const [purgeOpen, setPurgeOpen] = useState(false)
|
||
const [purgeBusy, setPurgeBusy] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
if (!enabled) return
|
||
const s = await getTrafficFlowSettings(backendUrl)
|
||
setSettings(s)
|
||
setCollectorIp(s.collectorIp)
|
||
setFlowPort(String(s.flowListenPort))
|
||
setWgPort(String(s.wgListenPort))
|
||
setPrefix(s.prefix)
|
||
setEndpoint(s.publicEndpoint)
|
||
setRetention(String(s.retentionHours))
|
||
setTopN(String(s.topN))
|
||
const pct = Number(s.mapServiceMinSharePct ?? 5)
|
||
setShareOn(pct > 0)
|
||
setSharePct(String(pct > 0 ? pct : 5))
|
||
setIngestOn(s.enabled)
|
||
}, [backendUrl, enabled])
|
||
|
||
useEffect(() => {
|
||
void load().catch((e: unknown) => {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось загрузить NetFlow")
|
||
})
|
||
}, [load])
|
||
|
||
async function handleSave() {
|
||
setBusy(true)
|
||
try {
|
||
const res = await putTrafficFlowSettings(backendUrl, {
|
||
enabled: ingestOn,
|
||
collectorIp,
|
||
flowListenPort: Number.parseInt(flowPort, 10) || 4739,
|
||
wgListenPort: Number.parseInt(wgPort, 10) || 51821,
|
||
prefix,
|
||
publicEndpoint: endpoint,
|
||
retentionHours: Number.parseInt(retention, 10) || 24,
|
||
topN: Number.parseInt(topN, 10) || 200,
|
||
mapServiceMinSharePct: shareOn
|
||
? Math.min(100, Math.max(1, Number.parseFloat(sharePct) || 5))
|
||
: 0,
|
||
})
|
||
setSettings(res.settings)
|
||
toast.success("Настройки NetFlow сохранены")
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleKeys() {
|
||
setBusy(true)
|
||
try {
|
||
const res = await generateTrafficFlowKeys(backendUrl)
|
||
setSettings(res.settings)
|
||
toast.success(res.created ? "Ключи хоста созданы" : "Ключи уже есть")
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось сгенерировать ключи")
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handlePurgeConfirm() {
|
||
setPurgeBusy(true)
|
||
try {
|
||
const result = await purgeTrafficFlowData(backendUrl)
|
||
toast.success(formatFlowPurgeResult(result))
|
||
setPurgeOpen(false)
|
||
await load()
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
||
} finally {
|
||
setPurgeBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleExport() {
|
||
setBusy(true)
|
||
try {
|
||
const res = await getTrafficFlowHostFiles(backendUrl)
|
||
setFormats(res.files.map((f) => ({
|
||
id: f.id,
|
||
label: f.label,
|
||
filename: f.filename,
|
||
code: f.code,
|
||
})))
|
||
setExportOpen(true)
|
||
await load()
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось получить файлы")
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<OpsPanel
|
||
title="Traffic Flow / NetFlow (IPFIX)"
|
||
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM. Preview: https://reui.io/preview/base/settings-16"
|
||
headerRight={
|
||
<div className="flex items-center gap-2">
|
||
{settings?.listenerBound ? (
|
||
<Badge variant="success">listener {settings.listenerAddress}</Badge>
|
||
) : (
|
||
<Badge variant="secondary">listener выкл</Badge>
|
||
)}
|
||
</div>
|
||
}
|
||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||
>
|
||
<Alert>
|
||
<InfoIcon />
|
||
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
||
<AlertDescription>
|
||
Приватный ключ хранится в PostgreSQL панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
|
||
</AlertDescription>
|
||
</Alert>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<FormToggle checked={ingestOn} onChange={setIngestOn} />
|
||
<span className="text-sm">Принимать IPFIX</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||
<Input className="font-mono" value={collectorIp} onChange={(e) => setCollectorIp(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Префикс overlay">
|
||
<Input className="font-mono" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="UDP IPFIX">
|
||
<Input className="font-mono" value={flowPort} onChange={(e) => setFlowPort(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="WG listen">
|
||
<Input className="font-mono" value={wgPort} onChange={(e) => setWgPort(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH" required>
|
||
<Input className="font-mono" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="203.0.113.10" />
|
||
</FormField>
|
||
<FormField label="Public key хоста">
|
||
<Input className="font-mono text-xs" readOnly value={settings?.hostPublicKey || "— сгенерируйте ключи —"} />
|
||
</FormField>
|
||
<FormField label="Хранение (часов)">
|
||
<Input value={retention} onChange={(e) => setRetention(e.target.value)} inputMode="numeric" />
|
||
</FormField>
|
||
<FormField label="Top-N разговоров">
|
||
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
|
||
</FormField>
|
||
<div className="sm:col-span-2 flex flex-col gap-2">
|
||
<div className="flex items-center gap-3">
|
||
<FormToggle
|
||
checked={shareOn}
|
||
onChange={(on) => {
|
||
setShareOn(on)
|
||
if (on && (!sharePct || sharePct === "0")) setSharePct("5")
|
||
}}
|
||
/>
|
||
<span className="text-sm">Порог доли на карте</span>
|
||
</div>
|
||
<FormField
|
||
label="Минимум % окна"
|
||
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
|
||
>
|
||
<Input
|
||
value={sharePct}
|
||
onChange={(e) => setSharePct(e.target.value)}
|
||
inputMode="decimal"
|
||
disabled={!shareOn}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
Last datagram:{" "}
|
||
{settings?.lastDatagramAt
|
||
? new Date(settings.lastDatagramAt).toLocaleString("ru-RU")
|
||
: "—"}
|
||
{settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""}
|
||
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||
</p>
|
||
|
||
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
||
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||
{HOST_STEPS.map((s) => (
|
||
<li key={s}>{s}</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" disabled={busy || purgeBusy} onClick={() => { void handleSave() }}>
|
||
Сохранить NetFlow
|
||
</Button>
|
||
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleKeys() }}>
|
||
<KeyRoundIcon className="size-4" />
|
||
Ключи хоста
|
||
</Button>
|
||
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleExport() }}>
|
||
<DownloadIcon className="size-4" />
|
||
wg-quick / compose / firewall
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-4 rounded-md border border-destructive/30 px-4 py-3">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium">Сбросить данные NetFlow</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
Удалит сессии и агрегаты в PostgreSQL. Ключи WG и пиры не трогает.
|
||
</p>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
disabled={busy || purgeBusy}
|
||
onClick={() => setPurgeOpen(true)}
|
||
>
|
||
Сбросить
|
||
</Button>
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
<GeoipSettingsSection backendUrl={backendUrl} />
|
||
|
||
<CodeExportSheet
|
||
open={exportOpen}
|
||
onClose={() => setExportOpen(false)}
|
||
title="Файлы для хоста Docker MM"
|
||
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||
formats={formats}
|
||
/>
|
||
|
||
<NetflowPurgeConfirm
|
||
open={purgeOpen}
|
||
busy={purgeBusy}
|
||
onConfirm={() => { void handlePurgeConfirm() }}
|
||
onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export { NetflowSettingsPanel }
|