feat(netflow): автоматизировать настройку туннеля коллектора
This commit is contained in:
1 parent
fcca92706b
commit
db81cf0f97
33 files changed
+1503
-22
No files matched your search
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { can } from "@/lib/auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { AlertCircleIcon } from "lucide-react"
|
||||
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
|
||||
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
|
||||
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
|
||||
}) {
|
||||
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
|
||||
const [receivedAt, setReceivedAt] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [operationError, setOperationError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const operation = useRef<AbortController | null>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const accept = useCallback((value: TrafficFlowHostStatus) => {
|
||||
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
|
||||
}, [onConfigured])
|
||||
useEffect(() => () => { operation.current?.abort() }, [])
|
||||
useEffect(() => {
|
||||
if (!enabled || !can("mm:traffic:read") || busy) return
|
||||
const controller = new AbortController()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function refresh() {
|
||||
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
||||
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
|
||||
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
|
||||
}
|
||||
void refresh()
|
||||
return () => { controller.abort(); if (timer) clearTimeout(timer) }
|
||||
}, [accept, backendUrl, enabled, busy, revision])
|
||||
|
||||
async function apply() {
|
||||
if (operation.current) return
|
||||
const controller = new AbortController(); operation.current = controller
|
||||
setBusy(true); setOperationError(null)
|
||||
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
||||
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
|
||||
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
|
||||
}
|
||||
if (!enabled || !can("mm:traffic:read")) return null
|
||||
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
|
||||
<p className="text-sm font-medium">WireGuard на сервере MM</p>
|
||||
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
|
||||
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
|
||||
{status?.configured && status.reachable ? <>
|
||||
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
|
||||
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
|
||||
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
|
||||
</ul>
|
||||
</> : null}
|
||||
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
|
||||
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { FlowHostPanel } from "./flow-host-panel"
|
||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
|
||||
})
|
||||
setResult(res)
|
||||
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||
toast.success(`wg-flow на ${res.address}`)
|
||||
if (res.ok) toast.success(`wg-flow на ${res.address}`)
|
||||
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
|
||||
onDone?.(res)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
|
||||
Подключить jump-host
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
||||
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
@@ -170,13 +172,14 @@ function FlowOverlaySheet({
|
||||
</FormField>
|
||||
{result ? (
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<Alert variant="success">
|
||||
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
||||
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
||||
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FlowHostPanel backendUrl={backendUrl} />
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{result.steps.map((s) => (
|
||||
<li key={s} className="flex items-start gap-2 text-sm">
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 { GeoipSettingsSection } from "./geoip-settings-section"
|
||||
import { FlowHostPanel } from "./flow-host-panel"
|
||||
import {
|
||||
generateTrafficFlowKeys,
|
||||
getTrafficFlowHostFiles,
|
||||
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
|
||||
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } 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).",
|
||||
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
|
||||
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
|
||||
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
|
||||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
||||
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
|
||||
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||
]
|
||||
|
||||
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
|
||||
enabled: boolean
|
||||
}) {
|
||||
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
||||
const [automatic,setAutomatic] = useState(false)
|
||||
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
||||
const dirty = useRef(new Set<keyof NetflowDraft>())
|
||||
const [dirtyCount,setDirtyCount] = useState(0)
|
||||
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||||
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Префикс overlay">
|
||||
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="UDP IPFIX">
|
||||
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="WG listen">
|
||||
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
||||
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
|
||||
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
|
||||
@@ -201,14 +203,15 @@ function NetflowSettingsContent({
|
||||
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
|
||||
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</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.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
||||
|
||||
Reference in new issue
Block a user