Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 |
@@ -54,6 +54,7 @@ import {
|
|||||||
type SchedulerJobGridRow,
|
type SchedulerJobGridRow,
|
||||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -1210,6 +1211,8 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
|
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Журнал прогонов"
|
title="Журнал прогонов"
|
||||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||||
|
|||||||
+167
-39
@@ -11,13 +11,20 @@ import { StatusDot } from "@/components/status-dot"
|
|||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
|
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
|
||||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { getTrafficFlows } from "@/shared/api/traffic-flow"
|
||||||
|
import { listServers } from "@/shared/api/servers"
|
||||||
|
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
|
||||||
|
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import type { FlowStatsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import {
|
import {
|
||||||
IFACE_TYPE_LABEL,
|
IFACE_TYPE_LABEL,
|
||||||
@@ -53,6 +60,8 @@ interface BoundIfaceTraffic {
|
|||||||
userName: string
|
userName: string
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
interfaceType: InterfaceType
|
interfaceType: InterfaceType
|
||||||
|
peerPublicKey?: string
|
||||||
|
peerName?: string
|
||||||
comment: string
|
comment: string
|
||||||
serverId: string
|
serverId: string
|
||||||
serverName: string
|
serverName: string
|
||||||
@@ -141,6 +150,11 @@ function hashSeed(s: string): number {
|
|||||||
return Math.abs(h)
|
return Math.abs(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundIfaceLabel(c: Pick<BoundIfaceTraffic, "interfaceName" | "interfaceType" | "peerName">): string {
|
||||||
|
if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}`
|
||||||
|
return c.interfaceName
|
||||||
|
}
|
||||||
|
|
||||||
function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
||||||
return INIT_USERS.flatMap((u) =>
|
return INIT_USERS.flatMap((u) =>
|
||||||
u.bindings.map((b) => {
|
u.bindings.map((b) => {
|
||||||
@@ -149,13 +163,15 @@ function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
|||||||
const rxNow = offline ? 0 : 12 + (seed % 140)
|
const rxNow = offline ? 0 : 12 + (seed % 140)
|
||||||
const txNow = offline ? 0 : 8 + (seed % 110)
|
const txNow = offline ? 0 : 8 + (seed % 110)
|
||||||
return {
|
return {
|
||||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
|
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`,
|
||||||
bindingId: b.id,
|
bindingId: b.id,
|
||||||
userId: u.id,
|
userId: u.id,
|
||||||
userLogin: u.login,
|
userLogin: u.login,
|
||||||
userName: u.name,
|
userName: u.name,
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey,
|
||||||
|
peerName: b.peerName,
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
serverId: b.serverId,
|
serverId: b.serverId,
|
||||||
serverName: b.serverName,
|
serverName: b.serverName,
|
||||||
@@ -267,7 +283,7 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
|
|||||||
"24h": "24ч",
|
"24h": "24ч",
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupMode = "servers" | "users" | "ifaces"
|
type GroupMode = "servers" | "users" | "ifaces" | "flows"
|
||||||
type SortField = "rx" | "tx" | "name" | "sessions"
|
type SortField = "rx" | "tx" | "name" | "sessions"
|
||||||
type SortDir = "asc" | "desc"
|
type SortDir = "asc" | "desc"
|
||||||
|
|
||||||
@@ -397,7 +413,7 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b
|
|||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<StatusDot status={c.status} />
|
<StatusDot status={c.status} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-mono font-medium truncate">{c.interfaceName}</p>
|
<p className="text-xs font-mono font-medium truncate">{boundIfaceLabel(c)}</p>
|
||||||
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +451,7 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
|
|||||||
<StatusDot status={c.status} />
|
<StatusDot status={c.status} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-mono font-semibold">{c.interfaceName}</span>
|
<span className="text-xs font-mono font-semibold">{boundIfaceLabel(c)}</span>
|
||||||
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
|
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
|
||||||
{showServer && (
|
{showServer && (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
@@ -652,7 +668,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
|
|||||||
<DetailHeader range={range} setRange={setRange}>
|
<DetailHeader range={range} setRange={setRange}>
|
||||||
<StatusDot status={sel.status} />
|
<StatusDot status={sel.status} />
|
||||||
<div className="leading-tight min-w-0">
|
<div className="leading-tight min-w-0">
|
||||||
<h2 className="text-base font-mono font-semibold">{sel.interfaceName}</h2>
|
<h2 className="text-base font-mono font-semibold">{boundIfaceLabel(sel)}</h2>
|
||||||
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
|
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
|
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
|
||||||
@@ -692,6 +708,7 @@ const GROUP_MODES: Array<{ mode: GroupMode; icon: ReactNode; label: string }> =
|
|||||||
{ mode: "servers", icon: <ServerIcon className="size-3" />, label: "Серверы" },
|
{ mode: "servers", icon: <ServerIcon className="size-3" />, label: "Серверы" },
|
||||||
{ mode: "users", icon: <UsersIcon className="size-3" />, label: "Клиенты" },
|
{ mode: "users", icon: <UsersIcon className="size-3" />, label: "Клиенты" },
|
||||||
{ mode: "ifaces", icon: <CableIcon className="size-3" />, label: "Интерфейсы" },
|
{ mode: "ifaces", icon: <CableIcon className="size-3" />, label: "Интерфейсы" },
|
||||||
|
{ mode: "flows", icon: <GitBranchIcon className="size-3" />, label: "Потоки" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMode[] }> = [
|
const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMode[] }> = [
|
||||||
@@ -722,6 +739,9 @@ export default function TrafficPage() {
|
|||||||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||||||
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
||||||
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
||||||
|
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
|
||||||
|
const [overlayOpen, setOverlayOpen] = useState(false)
|
||||||
|
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
||||||
const effectiveMode: GroupMode = groupMode
|
const effectiveMode: GroupMode = groupMode
|
||||||
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
||||||
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
||||||
@@ -812,6 +832,30 @@ export default function TrafficPage() {
|
|||||||
void loadLiveTraffic(range)
|
void loadLiveTraffic(range)
|
||||||
}, [isLive, range, loadLiveTraffic])
|
}, [isLive, range, loadLiveTraffic])
|
||||||
|
|
||||||
|
const loadFlows = useCallback(async () => {
|
||||||
|
if (!isLive) return
|
||||||
|
setLiveBusy(true)
|
||||||
|
setLiveError(null)
|
||||||
|
try {
|
||||||
|
const stats = await getTrafficFlows(backendUrl, range)
|
||||||
|
setFlowStats(stats)
|
||||||
|
} catch (e) {
|
||||||
|
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
|
||||||
|
} finally {
|
||||||
|
setLiveBusy(false)
|
||||||
|
}
|
||||||
|
}, [isLive, backendUrl, range])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive || effectiveMode !== "flows") return
|
||||||
|
void loadFlows()
|
||||||
|
}, [isLive, effectiveMode, loadFlows])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) return
|
||||||
|
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
||||||
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||||||
const activeUserTraffic = isLive ? liveUsers : userTraffic
|
const activeUserTraffic = isLive ? liveUsers : userTraffic
|
||||||
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
|
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
|
||||||
@@ -854,7 +898,7 @@ export default function TrafficPage() {
|
|||||||
setGroupMode(next)
|
setGroupMode(next)
|
||||||
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
||||||
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
|
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
|
||||||
else setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
else if (next === "ifaces") setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
||||||
setSortField("rx")
|
setSortField("rx")
|
||||||
setSortDir("desc")
|
setSortDir("desc")
|
||||||
setSearch("")
|
setSearch("")
|
||||||
@@ -897,6 +941,7 @@ export default function TrafficPage() {
|
|||||||
return [...activeBoundIfaces]
|
return [...activeBoundIfaces]
|
||||||
.filter(c => !q
|
.filter(c => !q
|
||||||
|| c.interfaceName.toLowerCase().includes(q)
|
|| c.interfaceName.toLowerCase().includes(q)
|
||||||
|
|| (c.peerName ?? "").toLowerCase().includes(q)
|
||||||
|| c.comment.toLowerCase().includes(q)
|
|| c.comment.toLowerCase().includes(q)
|
||||||
|| c.userLogin.toLowerCase().includes(q))
|
|| c.userLogin.toLowerCase().includes(q))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -925,6 +970,68 @@ export default function TrafficPage() {
|
|||||||
|
|
||||||
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
|
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
|
||||||
|
|
||||||
|
const flowKpiItems = [
|
||||||
|
{
|
||||||
|
id: "exporters",
|
||||||
|
label: "Экспортёры",
|
||||||
|
value: String(flowStats?.exportersOnline ?? 0),
|
||||||
|
icon: <ServerIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
label: "Байт/мин",
|
||||||
|
value: flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—",
|
||||||
|
icon: <ActivityIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "src",
|
||||||
|
label: "Уник. src",
|
||||||
|
value: String(flowStats?.uniqueSrc ?? 0),
|
||||||
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proto",
|
||||||
|
label: "Топ протокол",
|
||||||
|
value: flowStats?.topProto ?? "—",
|
||||||
|
icon: <GitBranchIcon className="size-4" />,
|
||||||
|
iconClassName: "text-warning",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const counterKpiItems = [
|
||||||
|
{
|
||||||
|
id: "rx",
|
||||||
|
label: "RX сейчас",
|
||||||
|
value: fmtRate(totalRx),
|
||||||
|
icon: <ArrowDownIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tx",
|
||||||
|
label: "TX сейчас",
|
||||||
|
value: fmtRate(totalTx),
|
||||||
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peak-rx",
|
||||||
|
label: "Пик RX",
|
||||||
|
value: fmtRate(peakRx),
|
||||||
|
icon: <TrendingUpIcon className="size-4" />,
|
||||||
|
iconClassName: "text-warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peak-tx",
|
||||||
|
label: "Пик TX",
|
||||||
|
value: fmtRate(peakTx),
|
||||||
|
icon: <TrendingUpIcon className="size-4" />,
|
||||||
|
iconClassName: "text-warning",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -934,7 +1041,10 @@ export default function TrafficPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { void loadLiveTraffic(range) }}
|
onClick={() => {
|
||||||
|
if (effectiveMode === "flows") void loadFlows()
|
||||||
|
else void loadLiveTraffic(range)
|
||||||
|
}}
|
||||||
disabled={isLive && liveBusy}
|
disabled={isLive && liveBusy}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||||
@@ -965,40 +1075,57 @@ export default function TrafficPage() {
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка трафика"
|
aria-label="Сводка трафика"
|
||||||
items={[
|
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
|
||||||
{
|
|
||||||
id: "rx",
|
|
||||||
label: "RX сейчас",
|
|
||||||
value: fmtRate(totalRx),
|
|
||||||
icon: <ArrowDownIcon className="size-4" />,
|
|
||||||
iconClassName: "text-success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tx",
|
|
||||||
label: "TX сейчас",
|
|
||||||
value: fmtRate(totalTx),
|
|
||||||
icon: <ArrowUpIcon className="size-4" />,
|
|
||||||
iconClassName: "text-info",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "peak-rx",
|
|
||||||
label: "Пик RX",
|
|
||||||
value: fmtRate(peakRx),
|
|
||||||
icon: <TrendingUpIcon className="size-4" />,
|
|
||||||
iconClassName: "text-warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "peak-tx",
|
|
||||||
label: "Пик TX",
|
|
||||||
value: fmtRate(peakTx),
|
|
||||||
icon: <TrendingUpIcon className="size-4" />,
|
|
||||||
iconClassName: "text-warning",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{effectiveMode === "flows" ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TRAFFIC_RANGE_KEYS.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRange(key)}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||||||
|
range === key
|
||||||
|
? "border-primary bg-primary/10 text-primary font-medium"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TRAFFIC_RANGE_LABELS[key]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Подключить JH
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{liveError && (
|
||||||
|
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||||
|
{liveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DataPageCard>
|
||||||
|
<TrafficFlowsDataGrid rows={flowStats?.talkers ?? []} />
|
||||||
|
</DataPageCard>
|
||||||
|
<FlowOverlaySheet
|
||||||
|
open={overlayOpen}
|
||||||
|
onOpenChange={setOverlayOpen}
|
||||||
|
servers={catalogServers}
|
||||||
|
backendUrl={backendUrl}
|
||||||
|
onDone={() => { void loadFlows() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||||
|
|
||||||
{/* ── left panel ── */}
|
{/* ── left panel ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|
||||||
@@ -1117,6 +1244,7 @@ export default function TrafficPage() {
|
|||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import {
|
import {
|
||||||
ALL_SECTIONS,
|
ALL_SECTIONS,
|
||||||
INIT_USERS,
|
INIT_USERS,
|
||||||
|
bindingDiffKey,
|
||||||
userInitials,
|
userInitials,
|
||||||
type AppUser,
|
type AppUser,
|
||||||
type AppUserForm,
|
type AppUserForm,
|
||||||
@@ -106,19 +107,21 @@ export default function UsersPage() {
|
|||||||
const activeCount = users.filter((u) => u.active).length
|
const activeCount = users.filter((u) => u.active).length
|
||||||
|
|
||||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||||
const nextKeys = new Set(next.map((b) => `${b.serverId}::${b.interfaceName}`))
|
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||||
const prevKeys = new Map(prev.map((b) => [`${b.serverId}::${b.interfaceName}`, b] as const))
|
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||||
for (const b of prev) {
|
for (const b of prev) {
|
||||||
if (!nextKeys.has(`${b.serverId}::${b.interfaceName}`)) {
|
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||||
await deleteUserBinding(backendUrl, userId, b.id)
|
await deleteUserBinding(backendUrl, userId, b.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const b of next) {
|
for (const b of next) {
|
||||||
if (!prevKeys.has(`${b.serverId}::${b.interfaceName}`)) {
|
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||||
await createUserBinding(backendUrl, userId, {
|
await createUserBinding(backendUrl, userId, {
|
||||||
serverId: Number(b.serverId),
|
serverId: Number(b.serverId),
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey,
|
||||||
|
peerName: b.peerName,
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||||
|
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts",
|
||||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+114
-1
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
sampled_at TEXT NOT NULL,
|
sampled_at TEXT NOT NULL,
|
||||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -120,6 +121,47 @@ CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
|||||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
||||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
ON traffic_samples(server_id, interface_name, sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||||
|
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||||
|
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||||
|
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||||
|
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
host_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
host_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_server_id INTEGER,
|
||||||
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
last_datagram_at TEXT,
|
||||||
|
last_exporter_ip TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
packets_received INTEGER NOT NULL DEFAULT 0,
|
||||||
|
peers_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
bucket_at TEXT NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT NOT NULL,
|
||||||
|
proto INTEGER NOT NULL DEFAULT 0,
|
||||||
|
src_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||||
|
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
|
||||||
|
ON flow_buckets(server_id, bucket_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -533,18 +575,80 @@ CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
|||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
UNIQUE (server_id, interface_name)
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
||||||
ON user_interface_bindings(user_id);
|
ON user_interface_bindings(user_id);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Lightweight schema evolution for existing databases without migrations
|
// Lightweight schema evolution for existing databases without migrations
|
||||||
|
{
|
||||||
|
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
|
||||||
|
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
|
||||||
|
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
|
||||||
|
if (!bindCols.some((c) => c.name === "peer_public_key")) {
|
||||||
|
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
if (!bindCols.some((c) => c.name === "peer_name")) {
|
||||||
|
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
|
||||||
|
name?: string
|
||||||
|
unique?: number
|
||||||
|
}>
|
||||||
|
let hasPeerUnique = false
|
||||||
|
for (const idx of indexes) {
|
||||||
|
if (!idx.name || !idx.unique) continue
|
||||||
|
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||||
|
const names = info.map((c) => c.name)
|
||||||
|
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
|
||||||
|
hasPeerUnique = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasPeerUnique) {
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = OFF`)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE user_interface_bindings_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
INSERT INTO user_interface_bindings_new
|
||||||
|
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
|
||||||
|
SELECT id, user_id, server_id, interface_name, interface_type,
|
||||||
|
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
|
||||||
|
FROM user_interface_bindings;
|
||||||
|
DROP TABLE user_interface_bindings;
|
||||||
|
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
`)
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||||
if (!hasCountryColumn) {
|
if (!hasCountryColumn) {
|
||||||
@@ -611,6 +715,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
|||||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
||||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
||||||
}
|
}
|
||||||
|
if (!serverCols.some((c) => c.name === "mgmt_tunnel_ip")) {
|
||||||
|
sqlite.exec(`ALTER TABLE servers ADD COLUMN mgmt_tunnel_ip TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
|
||||||
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
||||||
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
||||||
@@ -656,6 +763,12 @@ SELECT 1, 1, 30, 14
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO traffic_flow_settings (id, enabled, collector_ip, flow_listen_port, wg_listen_port, prefix)
|
||||||
|
SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
|
||||||
|
`)
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||||
SELECT 1, 1, 15, 14
|
SELECT 1, 1, 15, 14
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const servers = sqliteTable("servers", {
|
|||||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||||
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
||||||
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
||||||
|
/** Адрес в оверлее wg-flow (экспортёр IPFIX), например 10.255.254.5 */
|
||||||
|
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
|
||||||
|
|
||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
@@ -158,12 +160,55 @@ export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
|
|||||||
|
|
||||||
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||||
|
|
||||||
|
export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
|
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
|
||||||
|
flowListenPort: integer("flow_listen_port").notNull().default(4739),
|
||||||
|
wgListenPort: integer("wg_listen_port").notNull().default(51821),
|
||||||
|
prefix: text("prefix").notNull().default("10.255.254.0/24"),
|
||||||
|
publicEndpoint: text("public_endpoint").notNull().default(""),
|
||||||
|
hostPublicKey: text("host_public_key").notNull().default(""),
|
||||||
|
hostPrivateKey: text("host_private_key").notNull().default(""),
|
||||||
|
hubServerId: integer("hub_server_id"),
|
||||||
|
retentionHours: integer("retention_hours").notNull().default(24),
|
||||||
|
topN: integer("top_n").notNull().default(200),
|
||||||
|
lastDatagramAt: text("last_datagram_at"),
|
||||||
|
lastExporterIp: text("last_exporter_ip"),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
packetsReceived: integer("packets_received").notNull().default(0),
|
||||||
|
peersJson: text("peers_json").notNull().default("[]"),
|
||||||
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowBuckets = sqliteTable("flow_buckets", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
serverId: integer("server_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
|
bucketAt: text("bucket_at").notNull(),
|
||||||
|
src: text("src").notNull(),
|
||||||
|
dst: text("dst").notNull(),
|
||||||
|
proto: integer("proto").notNull().default(0),
|
||||||
|
srcPort: integer("src_port").notNull().default(0),
|
||||||
|
dstPort: integer("dst_port").notNull().default(0),
|
||||||
|
bytes: integer("bytes").notNull().default(0),
|
||||||
|
packets: integer("packets").notNull().default(0),
|
||||||
|
inIface: text("in_iface").notNull().default(""),
|
||||||
|
}, (t) => [
|
||||||
|
uniqueIndex("idx_flow_buckets_unique").on(
|
||||||
|
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
serverId: integer("server_id")
|
serverId: integer("server_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => servers.id, { onDelete: "cascade" }),
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
interfaceName: text("interface_name").notNull(),
|
interfaceName: text("interface_name").notNull(),
|
||||||
|
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||||
sampledAt: text("sampled_at").notNull(),
|
sampledAt: text("sampled_at").notNull(),
|
||||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||||
txBytes: integer("tx_bytes").notNull().default(0),
|
txBytes: integer("tx_bytes").notNull().default(0),
|
||||||
@@ -570,11 +615,13 @@ export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
|
|||||||
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("other"),
|
.default("other"),
|
||||||
|
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||||
|
peerName: text("peer_name").notNull().default(""),
|
||||||
comment: text("comment").notNull().default(""),
|
comment: text("comment").notNull().default(""),
|
||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
uniqueIndex("idx_user_iface_bind_server_name").on(t.serverId, t.interfaceName),
|
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||||
@@ -592,6 +639,8 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
|||||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||||
|
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||||
|
export type FlowBucketRow = typeof flowBuckets.$inferSelect
|
||||||
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import execRoutes from "./routes/exec.js"
|
|||||||
import filtersRoutes from "./routes/filters.js"
|
import filtersRoutes from "./routes/filters.js"
|
||||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||||
import trafficRoutes from "./routes/traffic.js"
|
import trafficRoutes from "./routes/traffic.js"
|
||||||
|
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||||
import uptimeRoutes from "./routes/uptime.js"
|
import uptimeRoutes from "./routes/uptime.js"
|
||||||
import networkRoutes from "./routes/network.js"
|
import networkRoutes from "./routes/network.js"
|
||||||
@@ -27,6 +28,7 @@ import wireguardRoutes from "./routes/wireguard.js"
|
|||||||
import firewallRoutes from "./routes/firewall.js"
|
import firewallRoutes from "./routes/firewall.js"
|
||||||
import usersRoutes from "./routes/users.js"
|
import usersRoutes from "./routes/users.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||||
|
|
||||||
export async function buildApp(opts?: {
|
export async function buildApp(opts?: {
|
||||||
logger?: boolean
|
logger?: boolean
|
||||||
@@ -93,6 +95,7 @@ export async function buildApp(opts?: {
|
|||||||
await app.register(filtersRoutes, { prefix: "/api" })
|
await app.register(filtersRoutes, { prefix: "/api" })
|
||||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||||
await app.register(trafficRoutes, { prefix: "/api" })
|
await app.register(trafficRoutes, { prefix: "/api" })
|
||||||
|
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||||
await app.register(networkRoutes, { prefix: "/api" })
|
await app.register(networkRoutes, { prefix: "/api" })
|
||||||
@@ -112,8 +115,10 @@ export async function buildApp(opts?: {
|
|||||||
|
|
||||||
if (opts?.startScheduler !== false) {
|
if (opts?.startScheduler !== false) {
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
|
startTrafficFlowListener()
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
|
stopTrafficFlowListener()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
|
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||||
|
|
||||||
const sqlite = new Database(":memory:")
|
const sqlite = new Database(":memory:")
|
||||||
sqlite.pragma("foreign_keys = ON")
|
sqlite.pragma("foreign_keys = ON")
|
||||||
@@ -29,12 +30,14 @@ CREATE TABLE user_interface_bindings (
|
|||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
UNIQUE (server_id, interface_name)
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -55,8 +58,33 @@ assert.throws(
|
|||||||
"один интерфейс на сервере — один пользователь",
|
"один интерфейс на сервере — один пользователь",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||||
|
`).run()
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||||
|
`).run()
|
||||||
|
assert.throws(
|
||||||
|
() => sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||||
|
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
|
||||||
|
`).run(),
|
||||||
|
/UNIQUE/i,
|
||||||
|
"один пир — один пользователь",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeBindingPeer("wg", ""),
|
||||||
|
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||||
|
"WG без ключа — 400",
|
||||||
|
)
|
||||||
|
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||||
|
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||||
|
|
||||||
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
|
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
|
||||||
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
|
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
|
||||||
assert.equal(leftover.n, 0, "каскад: привязки удаляются вместе с пользователем")
|
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||||
|
|
||||||
console.log("users bindings unique+cascade tests ok")
|
console.log("users bindings unique+cascade tests ok")
|
||||||
|
|||||||
@@ -4,23 +4,32 @@ import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from
|
|||||||
assert.equal(mapRosInterfaceType("ether"), "ether")
|
assert.equal(mapRosInterfaceType("ether"), "ether")
|
||||||
assert.equal(mapRosInterfaceType("ethernet"), "ether")
|
assert.equal(mapRosInterfaceType("ethernet"), "ether")
|
||||||
assert.equal(mapRosInterfaceType("GRE"), "gre")
|
assert.equal(mapRosInterfaceType("GRE"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
|
||||||
assert.equal(mapRosInterfaceType("wg"), "wg")
|
assert.equal(mapRosInterfaceType("wg"), "wg")
|
||||||
assert.equal(mapRosInterfaceType("wireguard"), "wg")
|
assert.equal(mapRosInterfaceType("wireguard"), "wg")
|
||||||
assert.equal(mapRosInterfaceType("vlan"), "other")
|
assert.equal(mapRosInterfaceType("vlan"), "other")
|
||||||
assert.equal(mapRosInterfaceType(""), "other")
|
assert.equal(mapRosInterfaceType(""), "other")
|
||||||
|
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
|
||||||
|
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
|
||||||
|
|
||||||
const parsed = parseRawInterfaces(JSON.stringify([
|
const parsed = parseRawInterfaces(JSON.stringify([
|
||||||
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
|
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
|
||||||
{ name: "gre-office", type: "gre", running: "false", disabled: "false" },
|
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
|
||||||
{ name: "wg-msk", type: "wg", running: true, disabled: false },
|
{ name: "wg-msk", type: "wg", running: true, disabled: false },
|
||||||
|
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
|
||||||
{ name: "", type: "ether" },
|
{ name: "", type: "ether" },
|
||||||
]))
|
]))
|
||||||
assert.equal(parsed.length, 3)
|
assert.equal(parsed.length, 4)
|
||||||
assert.equal(parsed[0]?.type, "ether")
|
assert.equal(parsed[0]?.type, "ether")
|
||||||
assert.equal(parsed[0]?.running, true)
|
assert.equal(parsed[0]?.running, true)
|
||||||
assert.equal(parsed[1]?.type, "gre")
|
assert.equal(parsed[1]?.type, "gre")
|
||||||
assert.equal(parsed[1]?.running, false)
|
assert.equal(parsed[1]?.running, false)
|
||||||
assert.equal(parsed[2]?.type, "wg")
|
assert.equal(parsed[2]?.type, "wg")
|
||||||
|
assert.equal(parsed[3]?.type, "gre")
|
||||||
|
|
||||||
assert.equal(parseRawInterfaces("not-json").length, 0)
|
assert.equal(parseRawInterfaces("not-json").length, 0)
|
||||||
assert.equal(parseRawInterfaces(null).length, 0)
|
assert.equal(parseRawInterfaces(null).length, 0)
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
export type InterfaceType = "ether" | "gre" | "wg" | "other"
|
export type InterfaceType = "ether" | "gre" | "wg" | "other"
|
||||||
|
|
||||||
export function mapRosInterfaceType(raw: string | undefined | null): InterfaceType {
|
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
|
||||||
const t = String(raw ?? "").trim().toLowerCase()
|
const t = String(raw ?? "").trim().toLowerCase()
|
||||||
if (t === "ether" || t === "ethernet") return "ether"
|
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
|
||||||
if (t === "gre") return "gre"
|
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
|
||||||
|
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
|
||||||
if (t === "wg" || t === "wireguard") return "wg"
|
if (t === "wg" || t === "wireguard") return "wg"
|
||||||
|
|
||||||
|
const n = String(name ?? "").trim().toLowerCase()
|
||||||
|
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
|
||||||
|
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
|
||||||
|
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
|
||||||
return "other"
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +40,7 @@ export function parseRawInterfaces(json: string | null | undefined): ParsedRosIf
|
|||||||
if (!name) continue
|
if (!name) continue
|
||||||
out.push({
|
out.push({
|
||||||
name,
|
name,
|
||||||
type: mapRosInterfaceType(String(rec.type ?? "")),
|
type: mapRosInterfaceType(String(rec.type ?? ""), name),
|
||||||
running: asBool(rec.running),
|
running: asBool(rec.running),
|
||||||
disabled: asBool(rec.disabled),
|
disabled: asBool(rec.disabled),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { InterfaceType } from "./iface-type.js"
|
||||||
|
|
||||||
|
export class PeerBindError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "PeerBindError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncPeerKey(key: string): string {
|
||||||
|
const k = key.trim()
|
||||||
|
if (k.length <= 20) return k
|
||||||
|
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peerDisplayName(opts: {
|
||||||
|
publicKey: string
|
||||||
|
name?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}): string {
|
||||||
|
const name = (opts.name ?? "").trim()
|
||||||
|
if (name) return name
|
||||||
|
const comment = (opts.comment ?? "").trim()
|
||||||
|
if (comment) return comment
|
||||||
|
return truncPeerKey(opts.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
|
||||||
|
export function normalizeBindingPeer(
|
||||||
|
type: InterfaceType,
|
||||||
|
peerPublicKey: string | undefined,
|
||||||
|
): string {
|
||||||
|
const key = (peerPublicKey ?? "").trim()
|
||||||
|
if (type === "wg") {
|
||||||
|
if (!key) {
|
||||||
|
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -46,9 +46,10 @@ export function getBindingRowById(id: string): BindingRow | undefined {
|
|||||||
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBindingByServerIface(
|
export function getBindingByServerIfacePeer(
|
||||||
serverId: number,
|
serverId: number,
|
||||||
interfaceName: string,
|
interfaceName: string,
|
||||||
|
peerPublicKey = "",
|
||||||
): BindingRow | undefined {
|
): BindingRow | undefined {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -56,6 +57,7 @@ export function getBindingByServerIface(
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(userInterfaceBindings.serverId, serverId),
|
eq(userInterfaceBindings.serverId, serverId),
|
||||||
eq(userInterfaceBindings.interfaceName, interfaceName),
|
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||||
|
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||||
))
|
))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.all()[0]
|
.all()[0]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
createUserRow,
|
createUserRow,
|
||||||
deleteBindingRowById,
|
deleteBindingRowById,
|
||||||
deleteUserRowById,
|
deleteUserRowById,
|
||||||
getBindingByServerIface,
|
getBindingByServerIfacePeer,
|
||||||
getBindingRowById,
|
getBindingRowById,
|
||||||
getUserRowById,
|
getUserRowById,
|
||||||
getUserRowByLogin,
|
getUserRowByLogin,
|
||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
mapRosInterfaceType,
|
mapRosInterfaceType,
|
||||||
parseRawInterfaces,
|
parseRawInterfaces,
|
||||||
} from "../iface-type.js"
|
} from "../iface-type.js"
|
||||||
|
import {
|
||||||
|
normalizeBindingPeer,
|
||||||
|
PeerBindError,
|
||||||
|
peerDisplayName,
|
||||||
|
} from "../peer-bind.js"
|
||||||
|
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||||
|
|
||||||
export class UsersServiceError extends Error {
|
export class UsersServiceError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -80,6 +86,8 @@ function toBindingDto(row: BindingRow): UserBinding {
|
|||||||
serverCountry: meta.country,
|
serverCountry: meta.country,
|
||||||
interfaceName: row.interfaceName,
|
interfaceName: row.interfaceName,
|
||||||
interfaceType: row.interfaceType,
|
interfaceType: row.interfaceType,
|
||||||
|
peerPublicKey: row.peerPublicKey ?? "",
|
||||||
|
peerName: row.peerName ?? "",
|
||||||
comment: row.comment,
|
comment: row.comment,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -179,11 +187,29 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
|||||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
const ifaceName = input.interfaceName.trim()
|
const ifaceName = input.interfaceName.trim()
|
||||||
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||||
const taken = getBindingByServerIface(input.serverId, ifaceName)
|
|
||||||
if (taken) {
|
|
||||||
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
|
|
||||||
}
|
|
||||||
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
||||||
|
let peerPublicKey = ""
|
||||||
|
try {
|
||||||
|
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const peerName = type === "wg"
|
||||||
|
? peerDisplayName({
|
||||||
|
publicKey: peerPublicKey,
|
||||||
|
name: input.peerName,
|
||||||
|
})
|
||||||
|
: ""
|
||||||
|
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||||
|
if (taken) {
|
||||||
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
const row = createBindingRow({
|
const row = createBindingRow({
|
||||||
@@ -192,6 +218,8 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
|||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
interfaceName: ifaceName,
|
interfaceName: ifaceName,
|
||||||
interfaceType: type,
|
interfaceType: type,
|
||||||
|
peerPublicKey,
|
||||||
|
peerName,
|
||||||
comment: (input.comment ?? "").trim(),
|
comment: (input.comment ?? "").trim(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -199,7 +227,12 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
|||||||
return toBindingDto(row)
|
return toBindingDto(row)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUniqueConstraintError(err)) {
|
if (isUniqueConstraintError(err)) {
|
||||||
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -220,7 +253,7 @@ function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
|
|||||||
return found?.type ?? "other"
|
return found?.type ?? "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
|
||||||
@@ -238,13 +271,14 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
|||||||
const rows = db
|
const rows = db
|
||||||
.select({
|
.select({
|
||||||
interfaceName: trafficSamples.interfaceName,
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
running: trafficSamples.running,
|
running: trafficSamples.running,
|
||||||
disabled: trafficSamples.disabled,
|
disabled: trafficSamples.disabled,
|
||||||
})
|
})
|
||||||
.from(trafficSamples)
|
.from(trafficSamples)
|
||||||
.where(eq(trafficSamples.serverId, serverId))
|
.where(eq(trafficSamples.serverId, serverId))
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName))
|
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
ifaces = []
|
ifaces = []
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -252,7 +286,7 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
|||||||
seen.add(r.interfaceName)
|
seen.add(r.interfaceName)
|
||||||
ifaces.push({
|
ifaces.push({
|
||||||
name: r.interfaceName,
|
name: r.interfaceName,
|
||||||
type: mapRosInterfaceType(""),
|
type: mapRosInterfaceType("", r.interfaceName),
|
||||||
running: Boolean(r.running),
|
running: Boolean(r.running),
|
||||||
disabled: Boolean(r.disabled),
|
disabled: Boolean(r.disabled),
|
||||||
})
|
})
|
||||||
@@ -262,18 +296,47 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
|||||||
|
|
||||||
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
|
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
|
||||||
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
|
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
|
||||||
|
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||||
|
const wgLive = hasWg
|
||||||
|
? await listWireGuardPeersForCatalog(serverId)
|
||||||
|
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||||
|
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||||
|
for (const peer of wgLive.peers) {
|
||||||
|
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||||
|
list.push(peer)
|
||||||
|
peersByIface.set(peer.interfaceName, list)
|
||||||
|
}
|
||||||
|
|
||||||
return ifaces.map((iface) => {
|
return ifaces.map((iface) => {
|
||||||
const bind = bindings.find((b) => b.interfaceName === iface.name)
|
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||||
const owner = bind ? usersById.get(bind.userId) : undefined
|
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||||
return {
|
const base: CatalogInterface = {
|
||||||
name: iface.name,
|
name: iface.name,
|
||||||
type: iface.type,
|
type: iface.type,
|
||||||
running: iface.running,
|
running: iface.running,
|
||||||
disabled: iface.disabled,
|
disabled: iface.disabled,
|
||||||
boundUserId: bind?.userId ?? null,
|
boundUserId: ifaceBind?.userId ?? null,
|
||||||
boundUserLogin: owner?.login ?? null,
|
boundUserLogin: owner?.login ?? null,
|
||||||
}
|
}
|
||||||
|
if (iface.type !== "wg") return base
|
||||||
|
const livePeers = peersByIface.get(iface.name) ?? []
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
peersError: wgLive.error,
|
||||||
|
peers: livePeers.map((p) => {
|
||||||
|
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||||
|
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||||
|
return {
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||||
|
comment: p.comment,
|
||||||
|
allowedIps: p.allowedIps,
|
||||||
|
latestHandshake: p.latestHandshake,
|
||||||
|
boundUserId: bind?.userId ?? null,
|
||||||
|
boundUserLogin: peerOwner?.login ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import {
|
||||||
|
trafficFlowOverlayRequestSchema,
|
||||||
|
trafficFlowSettingsPatchSchema,
|
||||||
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
|
import {
|
||||||
|
ensureHostKeys,
|
||||||
|
getTrafficFlowSettingsRow,
|
||||||
|
toTrafficFlowSettingsDto,
|
||||||
|
updateTrafficFlowSettings,
|
||||||
|
} from "../services/traffic-flow-settings.js"
|
||||||
|
import {
|
||||||
|
getFlowListenerState,
|
||||||
|
listFlowTalkers,
|
||||||
|
startTrafficFlowListener,
|
||||||
|
} from "../services/traffic-flow-ingest.js"
|
||||||
|
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||||
|
import {
|
||||||
|
buildHostComposeSnippet,
|
||||||
|
buildHostNftSnippet,
|
||||||
|
buildHostUfwSnippet,
|
||||||
|
buildHostWgQuickConf,
|
||||||
|
} from "../services/traffic-flow-host-files.js"
|
||||||
|
|
||||||
|
function rangeToMinutes(range: string | undefined): number {
|
||||||
|
switch ((range ?? "5m").toLowerCase()) {
|
||||||
|
case "5m": return 5
|
||||||
|
case "15m": return 15
|
||||||
|
case "1h": return 60
|
||||||
|
case "4h": return 240
|
||||||
|
case "24h": return 1440
|
||||||
|
default: return 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await applyFlowOverlay(parsed.data.serverId)
|
||||||
|
return reply.send(result)
|
||||||
|
} catch (e) {
|
||||||
|
const status = (e as { statusCode?: number }).statusCode ?? 502
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
return reply.status(status).send({ error: msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/traffic/flow/settings", async (_req, reply) => {
|
||||||
|
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/traffic/flow/settings", async (req, reply) => {
|
||||||
|
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
updateTrafficFlowSettings(parsed.data)
|
||||||
|
startTrafficFlowListener()
|
||||||
|
return reply.send({ ok: true, settings: toTrafficFlowSettingsDto(getFlowListenerState()) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
|
||||||
|
const result = ensureHostKeys()
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
created: result.created,
|
||||||
|
publicKey: result.publicKey,
|
||||||
|
settings: toTrafficFlowSettingsDto(getFlowListenerState()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
if (!row.hostPrivateKey) ensureHostKeys()
|
||||||
|
return reply.send({
|
||||||
|
files: [
|
||||||
|
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||||
|
{ id: "compose", label: "docker-compose", filename: "docker-compose.flow.yml", code: buildHostComposeSnippet() },
|
||||||
|
{ id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() },
|
||||||
|
{ id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||||
|
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||||
|
|
||||||
|
app.get("/traffic/flow", sendFlowTalkers)
|
||||||
|
app.get("/traffic/flows", sendFlowTalkers)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default trafficFlowRoutes
|
||||||
@@ -39,7 +39,7 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}, async (req, reply) => {
|
}, async (req, reply) => {
|
||||||
const q = req.query as { serverId: number }
|
const q = req.query as { serverId: number }
|
||||||
try {
|
try {
|
||||||
return reply.send({ interfaces: listInterfaceCatalog(q.serverId) })
|
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendServiceError(reply, err)
|
return sendServiceError(reply, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
getEnabledServerById,
|
getEnabledServerById,
|
||||||
listWireGuardInterfaces,
|
listWireGuardInterfaces,
|
||||||
} from "../services/wireguard-live.js"
|
} from "../services/wireguard-live.js"
|
||||||
|
import {
|
||||||
|
putIpAddress,
|
||||||
|
putWireguardInterface,
|
||||||
|
putWireguardPeer,
|
||||||
|
toRosBody,
|
||||||
|
} from "../services/wireguard-ros.js"
|
||||||
|
|
||||||
function serverIdParam(v: string): string {
|
function serverIdParam(v: string): string {
|
||||||
return decodeURIComponent(v)
|
return decodeURIComponent(v)
|
||||||
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
|
|||||||
return decodeURIComponent(v)
|
return decodeURIComponent(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
|
||||||
const out: Record<string, string> = {}
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
if (v !== undefined && v !== "") out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
||||||
return toRosBody({
|
return toRosBody({
|
||||||
interface: p.interfaceName,
|
interface: p.interfaceName,
|
||||||
@@ -99,20 +97,17 @@ async function applyParsedConfig(
|
|||||||
comment: parsed.interface.comment,
|
comment: parsed.interface.comment,
|
||||||
disabled: parsed.interface.disabled ? "yes" : undefined,
|
disabled: parsed.interface.disabled ? "yes" : undefined,
|
||||||
})
|
})
|
||||||
await client.put("/interface/wireguard", ifaceBody)
|
await putWireguardInterface(client, ifaceBody)
|
||||||
|
|
||||||
if (parsed.interface.address) {
|
if (parsed.interface.address) {
|
||||||
await client.put("/ip/address", {
|
await putIpAddress(client, parsed.interface.address, name)
|
||||||
address: parsed.interface.address,
|
|
||||||
interface: name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let peersCreated = 0
|
let peersCreated = 0
|
||||||
for (const p of parsed.peers) {
|
for (const p of parsed.peers) {
|
||||||
if (!p.publicKey) continue
|
if (!p.publicKey) continue
|
||||||
await client.put(
|
await putWireguardPeer(
|
||||||
"/interface/wireguard/peers",
|
client,
|
||||||
peerToRosBody({
|
peerToRosBody({
|
||||||
interfaceName: name,
|
interfaceName: name,
|
||||||
publicKey: p.publicKey,
|
publicKey: p.publicKey,
|
||||||
@@ -164,30 +159,21 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
await client.put(
|
await putWireguardInterface(client, {
|
||||||
"/interface/wireguard",
|
|
||||||
toRosBody({
|
|
||||||
name: body.name,
|
name: body.name,
|
||||||
"listen-port": String(body.listenPort),
|
"listen-port": String(body.listenPort),
|
||||||
mtu: String(body.mtu),
|
mtu: String(body.mtu),
|
||||||
comment: body.comment,
|
comment: body.comment,
|
||||||
"private-key": body.privateKey,
|
"private-key": body.privateKey,
|
||||||
disabled: body.disabled ? "yes" : undefined,
|
disabled: body.disabled ? "yes" : undefined,
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
if (body.address) {
|
if (body.address) {
|
||||||
await client.put("/ip/address", {
|
await putIpAddress(client, body.address, body.name)
|
||||||
address: body.address,
|
|
||||||
interface: body.name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.peer) {
|
if (body.peer) {
|
||||||
await client.put(
|
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
|
||||||
"/interface/wireguard/peers",
|
|
||||||
peerToRosBody({ ...body.peer, interfaceName: body.name }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = await listWireGuardInterfaces({
|
const list = await listWireGuardInterfaces({
|
||||||
@@ -255,7 +241,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
await client.put("/interface/wireguard/peers", peerToRosBody(body))
|
await putWireguardPeer(client, peerToRosBody(body))
|
||||||
return reply.status(201).send({ ok: true })
|
return reply.status(201).send({ ok: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
|||||||
@@ -16,6 +16,38 @@ interface RosIfaceTraffic {
|
|||||||
"tx-bits-per-second"?: string
|
"tx-bits-per-second"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RosWgPeerTraffic {
|
||||||
|
interface?: string
|
||||||
|
name?: string
|
||||||
|
comment?: string
|
||||||
|
"public-key"?: string
|
||||||
|
rx?: string
|
||||||
|
tx?: string
|
||||||
|
disabled?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function waveKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRate(
|
||||||
|
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
|
||||||
|
key: string,
|
||||||
|
rxBytes: number,
|
||||||
|
txBytes: number,
|
||||||
|
nowMs: number,
|
||||||
|
): { rxBps: number; txBps: number } {
|
||||||
|
const prev = prevWave.get(key)
|
||||||
|
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||||
|
const rxBps = prev && Number.isFinite(prevMs)
|
||||||
|
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||||
|
: 0
|
||||||
|
const txBps = prev && Number.isFinite(prevMs)
|
||||||
|
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||||
|
: 0
|
||||||
|
return { rxBps, txBps }
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficCollectorState {
|
export interface TrafficCollectorState {
|
||||||
running: boolean
|
running: boolean
|
||||||
lastRunAt: string | null
|
lastRunAt: string | null
|
||||||
@@ -63,6 +95,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
|
|||||||
const rows = db
|
const rows = db
|
||||||
.select({
|
.select({
|
||||||
interfaceName: trafficSamples.interfaceName,
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
rxBytes: trafficSamples.rxBytes,
|
rxBytes: trafficSamples.rxBytes,
|
||||||
txBytes: trafficSamples.txBytes,
|
txBytes: trafficSamples.txBytes,
|
||||||
sampledAt: trafficSamples.sampledAt,
|
sampledAt: trafficSamples.sampledAt,
|
||||||
@@ -73,7 +106,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
|
|||||||
eq(trafficSamples.sampledAt, last.sampledAt),
|
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||||
))
|
))
|
||||||
.all()
|
.all()
|
||||||
return new Map(rows.map((r) => [r.interfaceName, r]))
|
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||||
@@ -116,14 +149,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
const txBytes = toNum(i["tx-byte"])
|
const txBytes = toNum(i["tx-byte"])
|
||||||
const running = (i.running ?? "false") === "true"
|
const running = (i.running ?? "false") === "true"
|
||||||
const disabled = (i.disabled ?? "false") === "true"
|
const disabled = (i.disabled ?? "false") === "true"
|
||||||
const prev = prevWave.get(interfaceName)
|
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
|
||||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
|
||||||
const rxBps = prev && Number.isFinite(prevMs)
|
|
||||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
|
||||||
: 0
|
|
||||||
const txBps = prev && Number.isFinite(prevMs)
|
|
||||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
|
||||||
: 0
|
|
||||||
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||||
sumRxMbps += bpsToMbps(rxBps)
|
sumRxMbps += bpsToMbps(rxBps)
|
||||||
sumTxMbps += bpsToMbps(txBps)
|
sumTxMbps += bpsToMbps(txBps)
|
||||||
@@ -131,6 +157,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
return {
|
return {
|
||||||
serverId: srv.id,
|
serverId: srv.id,
|
||||||
interfaceName,
|
interfaceName,
|
||||||
|
peerPublicKey: "",
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
rxBytes,
|
rxBytes,
|
||||||
txBytes,
|
txBytes,
|
||||||
@@ -140,6 +167,39 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
disabled,
|
disabled,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
try {
|
||||||
|
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
|
||||||
|
for (const p of peers) {
|
||||||
|
const interfaceName = (p.interface ?? "").trim()
|
||||||
|
const peerPublicKey = (p["public-key"] ?? "").trim()
|
||||||
|
if (!interfaceName || !peerPublicKey) continue
|
||||||
|
const rxBytes = toNum(p.rx)
|
||||||
|
const txBytes = toNum(p.tx)
|
||||||
|
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes"
|
||||||
|
const running = !disabled
|
||||||
|
const { rxBps, txBps } = sampleRate(
|
||||||
|
prevWave,
|
||||||
|
waveKey(interfaceName, peerPublicKey),
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
nowMs,
|
||||||
|
)
|
||||||
|
rows.push({
|
||||||
|
serverId: srv.id,
|
||||||
|
interfaceName,
|
||||||
|
peerPublicKey,
|
||||||
|
sampledAt: now,
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
rxBps,
|
||||||
|
txBps,
|
||||||
|
running,
|
||||||
|
disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* WG peers optional — iface samples already recorded */
|
||||||
|
}
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
db.insert(trafficSamples).values(rows).run()
|
db.insert(trafficSamples).values(rows).run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { generateNativeConf } from "./wireguard-config.js"
|
||||||
|
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||||
|
|
||||||
|
export function buildHostWgQuickConf(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
const peers = listHostPeers()
|
||||||
|
return generateNativeConf({
|
||||||
|
name: "wg-flow",
|
||||||
|
listenPort: row.wgListenPort,
|
||||||
|
mtu: 1420,
|
||||||
|
privateKey: row.hostPrivateKey || undefined,
|
||||||
|
address: `${row.collectorIp}/24`,
|
||||||
|
comment: "MikrotikManager traffic-flow collector",
|
||||||
|
peers: peers.map((p) => ({
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
allowedIps: p.allowedIps,
|
||||||
|
comment: p.name,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHostComposeSnippet(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
return `# IPFIX listener: публиковать UDP только на WG-адресе хоста, не на 0.0.0.0
|
||||||
|
# Поднимите wg-quick@wg-flow, затем раскомментируйте ports у backend.
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"
|
||||||
|
environment:
|
||||||
|
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHostNftSnippet(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
return `# Firewall хоста Docker MM (nftables). UDP ${row.flowListenPort} наружу НЕ открывать.
|
||||||
|
table inet filter {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0;
|
||||||
|
iifname "wg-flow" udp dport ${row.flowListenPort} accept
|
||||||
|
udp dport ${row.wgListenPort} accept comment "WireGuard handshake"
|
||||||
|
udp dport ${row.flowListenPort} drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ufw (если используете):
|
||||||
|
# ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'
|
||||||
|
# ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHostUfwSnippet(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
return [
|
||||||
|
`ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'`,
|
||||||
|
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { createSocket, type Socket } from "node:dgram"
|
||||||
|
import { desc, eq, gte, sql } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { flowBuckets, servers } from "../db/schema.js"
|
||||||
|
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
||||||
|
import {
|
||||||
|
getTrafficFlowSettingsRow,
|
||||||
|
recordFlowListenerError,
|
||||||
|
recordFlowPacket,
|
||||||
|
} from "./traffic-flow-settings.js"
|
||||||
|
|
||||||
|
export interface FlowListenerState {
|
||||||
|
bound: boolean
|
||||||
|
address: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket: Socket | null = null
|
||||||
|
let state: FlowListenerState = { bound: false, address: null }
|
||||||
|
const pending = new Map<string, {
|
||||||
|
serverId: number
|
||||||
|
bucketAt: string
|
||||||
|
flow: ParsedFlow
|
||||||
|
bytes: number
|
||||||
|
packets: number
|
||||||
|
}>()
|
||||||
|
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
export function getFlowListenerState(): FlowListenerState {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function minuteBucketIso(at = Date.now()): string {
|
||||||
|
const d = new Date(at)
|
||||||
|
d.setSeconds(0, 0)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServerId(exporterIp: string): number | null {
|
||||||
|
const exact = db.select().from(servers).where(eq(servers.mgmtTunnelIp, exporterIp)).limit(1).all()[0]
|
||||||
|
return exact ? exact.id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueFlows(exporterIp: string, flows: ParsedFlow[]) {
|
||||||
|
const serverId = resolveServerId(exporterIp)
|
||||||
|
if (serverId == null) return
|
||||||
|
const bucketAt = minuteBucketIso()
|
||||||
|
for (const flow of flows) {
|
||||||
|
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}`
|
||||||
|
const prev = pending.get(key)
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += flow.bytes
|
||||||
|
prev.packets += flow.packets
|
||||||
|
} else {
|
||||||
|
pending.set(key, {
|
||||||
|
serverId,
|
||||||
|
bucketAt,
|
||||||
|
flow: { ...flow },
|
||||||
|
bytes: flow.bytes,
|
||||||
|
packets: flow.packets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPending() {
|
||||||
|
if (pending.size === 0) return
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const topN = Math.max(20, settings.topN)
|
||||||
|
const cutoff = new Date(Date.now() - settings.retentionHours * 3600_000).toISOString()
|
||||||
|
const rows = [...pending.values()]
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
db.insert(flowBuckets).values({
|
||||||
|
serverId: row.serverId,
|
||||||
|
bucketAt: row.bucketAt,
|
||||||
|
src: row.flow.src || "0.0.0.0",
|
||||||
|
dst: row.flow.dst || "0.0.0.0",
|
||||||
|
proto: row.flow.proto,
|
||||||
|
srcPort: row.flow.srcPort,
|
||||||
|
dstPort: row.flow.dstPort,
|
||||||
|
bytes: row.bytes,
|
||||||
|
packets: row.packets,
|
||||||
|
inIface: row.flow.inIface,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
flowBuckets.serverId,
|
||||||
|
flowBuckets.bucketAt,
|
||||||
|
flowBuckets.src,
|
||||||
|
flowBuckets.dst,
|
||||||
|
flowBuckets.proto,
|
||||||
|
flowBuckets.srcPort,
|
||||||
|
flowBuckets.dstPort,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
bytes: sql`${flowBuckets.bytes} + excluded.bytes`,
|
||||||
|
packets: sql`${flowBuckets.packets} + excluded.packets`,
|
||||||
|
},
|
||||||
|
}).run()
|
||||||
|
} catch {
|
||||||
|
// ignore single-row failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.delete(flowBuckets).where(sql`${flowBuckets.bucketAt} < ${cutoff}`).run()
|
||||||
|
|
||||||
|
const latest = db.select({ bucketAt: flowBuckets.bucketAt }).from(flowBuckets)
|
||||||
|
.orderBy(desc(flowBuckets.bucketAt)).limit(1).all()[0]?.bucketAt
|
||||||
|
if (!latest) return
|
||||||
|
const latestRows = db.select().from(flowBuckets).where(eq(flowBuckets.bucketAt, latest)).all()
|
||||||
|
const byServer = new Map<number, typeof latestRows>()
|
||||||
|
for (const r of latestRows) {
|
||||||
|
const list = byServer.get(r.serverId) ?? []
|
||||||
|
list.push(r)
|
||||||
|
byServer.set(r.serverId, list)
|
||||||
|
}
|
||||||
|
for (const list of byServer.values()) {
|
||||||
|
if (list.length <= topN) continue
|
||||||
|
list.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
for (const d of list.slice(topN)) {
|
||||||
|
db.delete(flowBuckets).where(eq(flowBuckets.id, d.id)).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(msg: Buffer, rinfo: { address: string }) {
|
||||||
|
try {
|
||||||
|
const flows = parseFlowPacket(msg, rinfo.address)
|
||||||
|
recordFlowPacket(rinfo.address)
|
||||||
|
if (flows.length) queueFlows(rinfo.address, flows)
|
||||||
|
} catch (e) {
|
||||||
|
recordFlowListenerError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTrafficFlowListener() {
|
||||||
|
if (flushTimer) {
|
||||||
|
clearInterval(flushTimer)
|
||||||
|
flushTimer = null
|
||||||
|
}
|
||||||
|
flushPending()
|
||||||
|
if (socket) {
|
||||||
|
try { socket.close() } catch { /* ignore */ }
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTrafficFlowListener() {
|
||||||
|
stopTrafficFlowListener()
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
if (!settings.enabled) {
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
|
||||||
|
const port = settings.flowListenPort
|
||||||
|
const sock = createSocket("udp4")
|
||||||
|
sock.on("error", (err) => {
|
||||||
|
recordFlowListenerError(err.message)
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
})
|
||||||
|
sock.on("message", onMessage)
|
||||||
|
sock.bind(port, host, () => {
|
||||||
|
state = { bound: true, address: `${host}:${port}` }
|
||||||
|
recordFlowListenerError("")
|
||||||
|
})
|
||||||
|
socket = sock
|
||||||
|
flushTimer = setInterval(flushPending, 15_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||||
|
const rangeStart = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||||
|
const rows = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, rangeStart)).all()
|
||||||
|
const serverRows = db.select().from(servers).all()
|
||||||
|
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||||
|
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||||
|
const protoBytes = new Map<number, number>()
|
||||||
|
const srcs = new Set<string>()
|
||||||
|
const dsts = new Set<string>()
|
||||||
|
const exporters = new Set<number>()
|
||||||
|
let totalBytes = 0
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}`
|
||||||
|
const prev = agg.get(key)
|
||||||
|
const bytes = r.bytes
|
||||||
|
totalBytes += bytes
|
||||||
|
srcs.add(r.src)
|
||||||
|
dsts.add(r.dst)
|
||||||
|
exporters.add(r.serverId)
|
||||||
|
protoBytes.set(r.proto, (protoBytes.get(r.proto) ?? 0) + bytes)
|
||||||
|
if (prev) {
|
||||||
|
prev.rawBytes += bytes
|
||||||
|
prev.bytes += bytes
|
||||||
|
prev.packets += r.packets
|
||||||
|
} else {
|
||||||
|
agg.set(key, {
|
||||||
|
serverId: String(r.serverId),
|
||||||
|
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
protoName: protoName(r.proto),
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
bytes,
|
||||||
|
packets: r.packets,
|
||||||
|
bps: 0,
|
||||||
|
inIface: r.inIface,
|
||||||
|
rawBytes: bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const windowSec = Math.max(60, minutes * 60)
|
||||||
|
const talkers = [...agg.values()]
|
||||||
|
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
.slice(0, getTrafficFlowSettingsRow().topN)
|
||||||
|
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||||
|
let topProto = "—"
|
||||||
|
let topProtoBytes = 0
|
||||||
|
for (const [p, b] of protoBytes) {
|
||||||
|
if (b > topProtoBytes) {
|
||||||
|
topProtoBytes = b
|
||||||
|
topProto = protoName(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exportersOnline: exporters.size,
|
||||||
|
bytesPerMin: minutes > 0 ? totalBytes / minutes : totalBytes,
|
||||||
|
uniqueSrc: srcs.size,
|
||||||
|
uniqueDst: dsts.size,
|
||||||
|
topProto,
|
||||||
|
talkers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
|
||||||
|
queueFlows(exporterIp, flows)
|
||||||
|
flushPending()
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { servers } from "../db/schema.js"
|
||||||
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||||
|
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
|
||||||
|
import {
|
||||||
|
asRosArray,
|
||||||
|
patchRosPath,
|
||||||
|
putIpAddress,
|
||||||
|
putWireguardInterface,
|
||||||
|
putWireguardPeer,
|
||||||
|
rosRowId,
|
||||||
|
toRosBody,
|
||||||
|
} from "./wireguard-ros.js"
|
||||||
|
import {
|
||||||
|
ensureHostKeys,
|
||||||
|
getTrafficFlowSettingsRow,
|
||||||
|
upsertHostPeer,
|
||||||
|
} from "./traffic-flow-settings.js"
|
||||||
|
|
||||||
|
const IFACE_NAME = "wg-flow"
|
||||||
|
const JH_LISTEN_PORT = 13232
|
||||||
|
const WG_INPUT_COMMENT = "mm-wg-flow"
|
||||||
|
|
||||||
|
export function allocateOverlayAddress(prefix: string, collectorIp: string, serverId: number, taken: Set<string>): string {
|
||||||
|
const [base] = prefix.split("/")
|
||||||
|
const parts = (base ?? "10.255.254.0").split(".").map((n) => Number.parseInt(n, 10))
|
||||||
|
const a = parts[0] || 10
|
||||||
|
const b = parts[1] || 255
|
||||||
|
const c = parts[2] || 254
|
||||||
|
const preferredLast = 2 + ((serverId - 1) % 250)
|
||||||
|
const candidates = [preferredLast, ...Array.from({ length: 253 }, (_, i) => 2 + ((preferredLast - 2 + i) % 253))]
|
||||||
|
for (const last of candidates) {
|
||||||
|
const ip = `${a}.${b}.${c}.${last}`
|
||||||
|
if (ip === collectorIp) continue
|
||||||
|
if (taken.has(ip)) continue
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
throw new Error("Нет свободных адресов в префиксе wg-flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
function linuxPeerBlock(publicKey: string, address: string, comment: string): string {
|
||||||
|
return [
|
||||||
|
`[Peer]`,
|
||||||
|
`PublicKey = ${publicKey}`,
|
||||||
|
`AllowedIPs = ${address}/32`,
|
||||||
|
comment ? `# ${comment}` : "",
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findIface(client: MikrotikClient, name: string): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard"))
|
||||||
|
return list.find((i) => String(i.name ?? "") === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPeer(
|
||||||
|
client: MikrotikClient,
|
||||||
|
iface: string,
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard/peers"))
|
||||||
|
return list.find((p) =>
|
||||||
|
String(p.interface ?? "") === iface && String(p["public-key"] ?? "") === publicKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAddress(client: MikrotikClient, iface: string): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/address"))
|
||||||
|
return list.find((a) => String(a.interface ?? "") === iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRoute(client: MikrotikClient, dst: string): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/route"))
|
||||||
|
return list.find((r) => String(r["dst-address"] ?? "") === dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): Promise<boolean> {
|
||||||
|
const rules = asRosArray<Record<string, unknown>>(await client.get("/ip/firewall/filter"))
|
||||||
|
const existing = rules.find((r) => String(r.comment ?? "") === WG_INPUT_COMMENT)
|
||||||
|
if (existing) return false
|
||||||
|
await client.put("/ip/firewall/filter", toRosBody({
|
||||||
|
chain: "input",
|
||||||
|
protocol: "udp",
|
||||||
|
"dst-port": String(listenPort),
|
||||||
|
action: "accept",
|
||||||
|
comment: WG_INPUT_COMMENT,
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFlowInterfaces(client: MikrotikClient): Promise<string> {
|
||||||
|
const ifaces = asRosArray<{ name?: string; type?: string; disabled?: string }>(await client.get("/interface"))
|
||||||
|
const names = ifaces
|
||||||
|
.filter((i) => {
|
||||||
|
if ((i.disabled ?? "false") === "true") return false
|
||||||
|
const name = i.name ?? ""
|
||||||
|
if (!name || name === IFACE_NAME || /^lo/i.test(name)) return false
|
||||||
|
const type = (i.type ?? "").toLowerCase()
|
||||||
|
return type.includes("ether") || type.includes("gre") || type === "vlan"
|
||||||
|
})
|
||||||
|
.map((i) => i.name ?? "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 8)
|
||||||
|
return names.join(",") || "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTrafficFlow(client: MikrotikClient, collectorIp: string, port: number): Promise<void> {
|
||||||
|
const interfaces = await listFlowInterfaces(client)
|
||||||
|
try {
|
||||||
|
await client.patch("/ip/traffic-flow", toRosBody({
|
||||||
|
enabled: "yes",
|
||||||
|
interfaces,
|
||||||
|
"active-flow-timeout": "1m",
|
||||||
|
"inactive-flow-timeout": "15s",
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
await client.put("/ip/traffic-flow", toRosBody({
|
||||||
|
enabled: "yes",
|
||||||
|
interfaces,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
||||||
|
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
||||||
|
const body = toRosBody({
|
||||||
|
"dst-address": collectorIp,
|
||||||
|
port: String(port),
|
||||||
|
version: "ipfix",
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
const id = rosRowId(existing)
|
||||||
|
if (id) await patchRosPath(client, `/ip/traffic-flow/target/${encodeURIComponent(id)}`, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await client.put("/ip/traffic-flow/target", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyFlowOverlay(serverIdRaw: string | number): Promise<TrafficFlowOverlayResult> {
|
||||||
|
const steps: string[] = []
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const keys = ensureHostKeys()
|
||||||
|
if (!settings.hostPublicKey && !keys.publicKey) {
|
||||||
|
throw Object.assign(new Error("Сначала сгенерируйте ключи хоста MM в настройках NetFlow"), { statusCode: 400 })
|
||||||
|
}
|
||||||
|
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||||
|
if (!settings.publicEndpoint.trim()) {
|
||||||
|
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = getEnabledServerById(String(serverIdRaw))
|
||||||
|
if (!server || !server.enabled) {
|
||||||
|
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = new Set(
|
||||||
|
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
||||||
|
.map((r) => r.ip)
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const address = server.mgmtTunnelIp || allocateOverlayAddress(settings.prefix, settings.collectorIp, server.id, taken)
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let iface = await findIface(client, IFACE_NAME)
|
||||||
|
if (!iface) {
|
||||||
|
await putWireguardInterface(client, {
|
||||||
|
name: IFACE_NAME,
|
||||||
|
"listen-port": String(JH_LISTEN_PORT),
|
||||||
|
mtu: "1420",
|
||||||
|
comment: "MikrotikManager traffic-flow overlay",
|
||||||
|
})
|
||||||
|
steps.push(`Создан интерфейс ${IFACE_NAME}`)
|
||||||
|
iface = await findIface(client, IFACE_NAME)
|
||||||
|
} else {
|
||||||
|
steps.push(`Интерфейс ${IFACE_NAME} уже есть`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addrRow = await findAddress(client, IFACE_NAME)
|
||||||
|
const mask = (settings.prefix.split("/")[1] || "24").replace(/\D/g, "") || "24"
|
||||||
|
const cidr = `${address}/${mask}`
|
||||||
|
if (!addrRow) {
|
||||||
|
await putIpAddress(client, cidr, IFACE_NAME)
|
||||||
|
steps.push(`Адрес ${cidr}`)
|
||||||
|
} else {
|
||||||
|
steps.push(`Адрес на ${IFACE_NAME} уже назначен`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
|
||||||
|
const endpointHost = settings.publicEndpoint.trim()
|
||||||
|
const peerBody = {
|
||||||
|
interface: IFACE_NAME,
|
||||||
|
"public-key": hostPublicKey,
|
||||||
|
"allowed-address": `${settings.collectorIp}/32`,
|
||||||
|
"endpoint-address": endpointHost,
|
||||||
|
"endpoint-port": String(settings.wgListenPort),
|
||||||
|
"persistent-keepalive": "25",
|
||||||
|
comment: "MM traffic-flow collector",
|
||||||
|
name: "mm-collector",
|
||||||
|
}
|
||||||
|
if (!peer) {
|
||||||
|
await putWireguardPeer(client, peerBody)
|
||||||
|
steps.push("Добавлен пир на pubkey хоста MM")
|
||||||
|
} else {
|
||||||
|
const id = rosRowId(peer)
|
||||||
|
if (id) await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
|
||||||
|
steps.push("Пир хоста MM обновлён")
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeDst = `${settings.collectorIp}/32`
|
||||||
|
const route = await findRoute(client, routeDst)
|
||||||
|
if (!route) {
|
||||||
|
await client.put("/ip/route", toRosBody({
|
||||||
|
"dst-address": routeDst,
|
||||||
|
gateway: IFACE_NAME,
|
||||||
|
comment: "MM traffic-flow collector",
|
||||||
|
}))
|
||||||
|
steps.push(`Маршрут ${routeDst} через ${IFACE_NAME}`)
|
||||||
|
} else {
|
||||||
|
steps.push("Маршрут до collector уже есть")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await ensureWgInputAccept(client, JH_LISTEN_PORT)) {
|
||||||
|
steps.push(`Firewall input accept UDP ${JH_LISTEN_PORT}`)
|
||||||
|
} else {
|
||||||
|
steps.push("Firewall input WG уже есть")
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
||||||
|
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix`)
|
||||||
|
|
||||||
|
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
||||||
|
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
||||||
|
const publicKey = created?.publicKey ?? ""
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error("Не удалось прочитать public-key интерфейса wg-flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(servers).set({
|
||||||
|
mgmtTunnelIp: address,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(servers.id, server.id)).run()
|
||||||
|
|
||||||
|
upsertHostPeer({
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name || server.host,
|
||||||
|
publicKey,
|
||||||
|
allowedIps: [`${address}/32`],
|
||||||
|
address,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
serverId: server.id,
|
||||||
|
interfaceName: IFACE_NAME,
|
||||||
|
address,
|
||||||
|
publicKey,
|
||||||
|
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host),
|
||||||
|
trafficFlow: true,
|
||||||
|
steps,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||||
|
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js"
|
||||||
|
import { allocateOverlayAddress } from "./traffic-flow-overlay.js"
|
||||||
|
|
||||||
|
function netflowV5One(): Buffer {
|
||||||
|
const buf = Buffer.alloc(24 + 48)
|
||||||
|
buf.writeUInt16BE(5, 0)
|
||||||
|
buf.writeUInt16BE(1, 2)
|
||||||
|
buf[24] = 10; buf[25] = 1; buf[26] = 1; buf[27] = 8
|
||||||
|
buf[28] = 8; buf[29] = 8; buf[30] = 8; buf[31] = 8
|
||||||
|
buf.writeUInt16BE(1, 24 + 12)
|
||||||
|
buf.writeUInt32BE(10, 24 + 16)
|
||||||
|
buf.writeUInt32BE(1500, 24 + 20)
|
||||||
|
buf.writeUInt16BE(443, 24 + 32)
|
||||||
|
buf.writeUInt16BE(443, 24 + 34)
|
||||||
|
buf.writeUInt8(6, 24 + 38)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowTemplatesForTests()
|
||||||
|
const flows = parseFlowPacket(netflowV5One(), "10.255.254.5")
|
||||||
|
assert.equal(flows.length, 1)
|
||||||
|
assert.equal(flows[0]?.src, "10.1.1.8")
|
||||||
|
assert.equal(flows[0]?.dst, "8.8.8.8")
|
||||||
|
assert.equal(flows[0]?.proto, 6)
|
||||||
|
assert.equal(flows[0]?.bytes, 1500)
|
||||||
|
assert.equal(protoName(6), "TCP")
|
||||||
|
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
|
||||||
|
|
||||||
|
const taken = new Set(["10.255.254.2"])
|
||||||
|
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
|
||||||
|
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
||||||
|
|
||||||
|
console.log("traffic-flow-parse.test.ts: ok")
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
export interface ParsedFlow {
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
proto: number
|
||||||
|
srcPort: number
|
||||||
|
dstPort: number
|
||||||
|
bytes: number
|
||||||
|
packets: number
|
||||||
|
inIface: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldSpec {
|
||||||
|
type: number
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
fields: FieldSpec[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const templatesByExporter = new Map<string, Map<number, Template>>()
|
||||||
|
|
||||||
|
function ipv4(buf: Buffer, offset: number): string {
|
||||||
|
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||||
|
if (length === 1) return buf.readUInt8(offset)
|
||||||
|
if (length === 2) return buf.readUInt16BE(offset)
|
||||||
|
if (length === 4) return buf.readUInt32BE(offset)
|
||||||
|
if (length === 8) {
|
||||||
|
const big = buf.readBigUInt64BE(offset)
|
||||||
|
const n = Number(big)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
let v = 0
|
||||||
|
for (let i = 0; i < length; i++) v = (v << 8) + buf[offset + i]
|
||||||
|
return v >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
||||||
|
if (buf.length < 24) return []
|
||||||
|
const count = buf.readUInt16BE(2)
|
||||||
|
const out: ParsedFlow[] = []
|
||||||
|
let off = 24
|
||||||
|
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||||
|
out.push({
|
||||||
|
src: ipv4(buf, off),
|
||||||
|
dst: ipv4(buf, off + 4),
|
||||||
|
packets: buf.readUInt32BE(off + 16),
|
||||||
|
bytes: buf.readUInt32BE(off + 20),
|
||||||
|
srcPort: buf.readUInt16BE(off + 32),
|
||||||
|
dstPort: buf.readUInt16BE(off + 34),
|
||||||
|
proto: buf.readUInt8(off + 38),
|
||||||
|
inIface: String(buf.readUInt16BE(off + 12)),
|
||||||
|
})
|
||||||
|
off += 48
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
||||||
|
let off = setStart + 4
|
||||||
|
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
||||||
|
while (off + 4 <= setEnd) {
|
||||||
|
const templateId = buf.readUInt16BE(off)
|
||||||
|
const fieldCount = buf.readUInt16BE(off + 2)
|
||||||
|
off += 4
|
||||||
|
if (setId === 3) {
|
||||||
|
// options template: skip scope count
|
||||||
|
if (off + 2 > setEnd) break
|
||||||
|
off += 2
|
||||||
|
}
|
||||||
|
const fields: FieldSpec[] = []
|
||||||
|
for (let i = 0; i < fieldCount && off + 4 <= setEnd; i++) {
|
||||||
|
const type = buf.readUInt16BE(off)
|
||||||
|
const length = buf.readUInt16BE(off + 2)
|
||||||
|
off += 4
|
||||||
|
if (type & 0x8000) {
|
||||||
|
if (off + 4 > setEnd) break
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
fields.push({ type: type & 0x7fff, length })
|
||||||
|
}
|
||||||
|
if (templateId >= 256) map.set(templateId, { fields })
|
||||||
|
}
|
||||||
|
templatesByExporter.set(exporter, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFromFields(fields: FieldSpec[], buf: Buffer, offset: number): { flow: ParsedFlow; next: number } | null {
|
||||||
|
let off = offset
|
||||||
|
let src = ""
|
||||||
|
let dst = ""
|
||||||
|
let proto = 0
|
||||||
|
let srcPort = 0
|
||||||
|
let dstPort = 0
|
||||||
|
let bytes = 0
|
||||||
|
let packets = 0
|
||||||
|
let inIface = ""
|
||||||
|
for (const f of fields) {
|
||||||
|
if (off + f.length > buf.length) return null
|
||||||
|
switch (f.type) {
|
||||||
|
case 8:
|
||||||
|
if (f.length === 4) src = ipv4(buf, off)
|
||||||
|
break
|
||||||
|
case 12:
|
||||||
|
if (f.length === 4) dst = ipv4(buf, off)
|
||||||
|
break
|
||||||
|
case 4:
|
||||||
|
proto = readUint(buf, off, f.length)
|
||||||
|
break
|
||||||
|
case 7:
|
||||||
|
srcPort = readUint(buf, off, f.length)
|
||||||
|
break
|
||||||
|
case 11:
|
||||||
|
dstPort = readUint(buf, off, f.length)
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
bytes = readUint(buf, off, f.length)
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
packets = readUint(buf, off, f.length)
|
||||||
|
break
|
||||||
|
case 10:
|
||||||
|
inIface = String(readUint(buf, off, f.length))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
off += f.length
|
||||||
|
}
|
||||||
|
if (!src && !dst) return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
||||||
|
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||||
|
if (buf.length < 16) return []
|
||||||
|
const total = buf.readUInt16BE(2)
|
||||||
|
const end = Math.min(buf.length, total)
|
||||||
|
let off = 16
|
||||||
|
const out: ParsedFlow[] = []
|
||||||
|
while (off + 4 <= end) {
|
||||||
|
const setId = buf.readUInt16BE(off)
|
||||||
|
const setLen = buf.readUInt16BE(off + 2)
|
||||||
|
if (setLen < 4 || off + setLen > end) break
|
||||||
|
const setEnd = off + setLen
|
||||||
|
if (setId === 2 || setId === 3) {
|
||||||
|
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||||
|
} else if (setId >= 256) {
|
||||||
|
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||||
|
if (tpl) {
|
||||||
|
let recOff = off + 4
|
||||||
|
while (recOff + 1 < setEnd) {
|
||||||
|
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
||||||
|
if (!parsed) break
|
||||||
|
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||||
|
if (parsed.next <= recOff) break
|
||||||
|
recOff = parsed.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off = setEnd
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||||
|
if (buf.length < 20) return []
|
||||||
|
const count = buf.readUInt16BE(2)
|
||||||
|
let off = 20
|
||||||
|
const out: ParsedFlow[] = []
|
||||||
|
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
||||||
|
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
||||||
|
const setId = buf.readUInt16BE(off)
|
||||||
|
const setLen = buf.readUInt16BE(off + 2)
|
||||||
|
if (setLen < 4 || off + setLen > buf.length) break
|
||||||
|
const setEnd = off + setLen
|
||||||
|
if (setId === 0) {
|
||||||
|
let tOff = off + 4
|
||||||
|
while (tOff + 4 <= setEnd) {
|
||||||
|
const templateId = buf.readUInt16BE(tOff)
|
||||||
|
const fieldCount = buf.readUInt16BE(tOff + 2)
|
||||||
|
tOff += 4
|
||||||
|
const fields: FieldSpec[] = []
|
||||||
|
for (let i = 0; i < fieldCount && tOff + 4 <= setEnd; i++) {
|
||||||
|
fields.push({ type: buf.readUInt16BE(tOff), length: buf.readUInt16BE(tOff + 2) })
|
||||||
|
tOff += 4
|
||||||
|
}
|
||||||
|
if (templateId >= 256) map.set(templateId, { fields })
|
||||||
|
}
|
||||||
|
templatesByExporter.set(exporter, map)
|
||||||
|
} else if (setId >= 256) {
|
||||||
|
const tpl = map.get(setId)
|
||||||
|
if (tpl) {
|
||||||
|
let recOff = off + 4
|
||||||
|
while (recOff + 1 < setEnd) {
|
||||||
|
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
||||||
|
if (!parsed) break
|
||||||
|
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||||
|
if (parsed.next <= recOff) break
|
||||||
|
recOff = parsed.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off = setEnd
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFlowPacket(buf: Buffer, exporterIp: string): ParsedFlow[] {
|
||||||
|
if (buf.length < 2) return []
|
||||||
|
const version = buf.readUInt16BE(0)
|
||||||
|
if (version === 5) return parseNetflowV5(buf)
|
||||||
|
if (version === 9) return parseNetflowV9(buf, exporterIp)
|
||||||
|
if (version === 10) return parseIpfix(buf, exporterIp)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protoName(proto: number): string {
|
||||||
|
switch (proto) {
|
||||||
|
case 1: return "ICMP"
|
||||||
|
case 6: return "TCP"
|
||||||
|
case 17: return "UDP"
|
||||||
|
case 47: return "GRE"
|
||||||
|
case 50: return "ESP"
|
||||||
|
case 89: return "OSPF"
|
||||||
|
default: return String(proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFlowTemplatesForTests() {
|
||||||
|
templatesByExporter.clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { trafficFlowSettings } from "../db/schema.js"
|
||||||
|
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePeers(raw: string): FlowHostPeer[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter((p): p is FlowHostPeer =>
|
||||||
|
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrafficFlowSettingsRow() {
|
||||||
|
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||||
|
if (row) return row
|
||||||
|
const now = nowIso()
|
||||||
|
db.insert(trafficFlowSettings).values({
|
||||||
|
id: 1,
|
||||||
|
enabled: false,
|
||||||
|
collectorIp: "10.255.254.1",
|
||||||
|
flowListenPort: 4739,
|
||||||
|
wgListenPort: 51821,
|
||||||
|
prefix: "10.255.254.0/24",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}).run()
|
||||||
|
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTrafficFlowSettingsDto(
|
||||||
|
listener: { bound: boolean; address: string | null },
|
||||||
|
): TrafficFlowSettingsDto {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
return {
|
||||||
|
enabled: row.enabled,
|
||||||
|
collectorIp: row.collectorIp,
|
||||||
|
flowListenPort: row.flowListenPort,
|
||||||
|
wgListenPort: row.wgListenPort,
|
||||||
|
prefix: row.prefix,
|
||||||
|
publicEndpoint: row.publicEndpoint,
|
||||||
|
hostPublicKey: row.hostPublicKey,
|
||||||
|
hasHostPrivateKey: Boolean(row.hostPrivateKey),
|
||||||
|
hubServerId: row.hubServerId ?? null,
|
||||||
|
retentionHours: row.retentionHours,
|
||||||
|
topN: row.topN,
|
||||||
|
lastDatagramAt: row.lastDatagramAt ?? null,
|
||||||
|
lastExporterIp: row.lastExporterIp ?? null,
|
||||||
|
lastError: row.lastError || null,
|
||||||
|
packetsReceived: row.packetsReceived,
|
||||||
|
listenerBound: listener.bound,
|
||||||
|
listenerAddress: listener.address,
|
||||||
|
peers: parsePeers(row.peersJson),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
enabled: patch.enabled ?? row.enabled,
|
||||||
|
collectorIp: patch.collectorIp ?? row.collectorIp,
|
||||||
|
flowListenPort: patch.flowListenPort ?? row.flowListenPort,
|
||||||
|
wgListenPort: patch.wgListenPort ?? row.wgListenPort,
|
||||||
|
prefix: patch.prefix ?? row.prefix,
|
||||||
|
publicEndpoint: patch.publicEndpoint ?? row.publicEndpoint,
|
||||||
|
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
|
||||||
|
retentionHours: patch.retentionHours ?? row.retentionHours,
|
||||||
|
topN: patch.topN ?? row.topN,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
return getTrafficFlowSettingsRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
if (row.hostPublicKey && row.hostPrivateKey) {
|
||||||
|
return { publicKey: row.hostPublicKey, created: false }
|
||||||
|
}
|
||||||
|
const keys = generateWireGuardKeyPair()
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
hostPublicKey: keys.publicKey,
|
||||||
|
hostPrivateKey: keys.privateKey,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
return { publicKey: keys.publicKey, created: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertHostPeer(peer: FlowHostPeer) {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
const peers = parsePeers(row.peersJson).filter((p) => p.serverId !== peer.serverId)
|
||||||
|
peers.push(peer)
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
peersJson: JSON.stringify(peers),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordFlowPacket(exporterIp: string) {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
lastDatagramAt: nowIso(),
|
||||||
|
lastExporterIp: exporterIp,
|
||||||
|
packetsReceived: row.packetsReceived + 1,
|
||||||
|
lastError: "",
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordFlowListenerError(message: string) {
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
lastError: message,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listHostPeers(): FlowHostPeer[] {
|
||||||
|
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
||||||
|
}
|
||||||
@@ -97,4 +97,29 @@ const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, [
|
|||||||
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
||||||
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
||||||
|
|
||||||
|
const peerA: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const peerB: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const ifaceWg: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const mixed = [...peerA, ...peerB, ...ifaceWg]
|
||||||
|
const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a")
|
||||||
|
const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b")
|
||||||
|
const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server")
|
||||||
|
assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру")
|
||||||
|
assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate")
|
||||||
|
assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров")
|
||||||
|
assert.equal(
|
||||||
|
buildTrafficFromSamples(mixed, start, end).rxNow,
|
||||||
|
rateIface.rxNow,
|
||||||
|
"режим сервера игнорирует семплы пиров",
|
||||||
|
)
|
||||||
|
|
||||||
console.log("traffic-rate tests ok")
|
console.log("traffic-rate tests ok")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const SERIES_POINTS = 60
|
|||||||
|
|
||||||
export interface TrafficSampleLike {
|
export interface TrafficSampleLike {
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
|
peerPublicKey?: string
|
||||||
sampledAt: string
|
sampledAt: string
|
||||||
rxBytes: number
|
rxBytes: number
|
||||||
txBytes: number
|
txBytes: number
|
||||||
@@ -94,11 +95,16 @@ function parseIsoMs(iso: string): number {
|
|||||||
return Number.isFinite(t) ? t : 0
|
return Number.isFinite(t) ? t : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTrafficFromSamples(
|
export function buildTrafficFromSamples(
|
||||||
rows: TrafficSampleLike[],
|
rows: TrafficSampleLike[],
|
||||||
rangeStartMs: number,
|
rangeStartMs: number,
|
||||||
rangeEndMs: number,
|
rangeEndMs: number,
|
||||||
onlyInterface?: string | readonly string[],
|
onlyInterface?: string | readonly string[],
|
||||||
|
peerPublicKey?: string,
|
||||||
): BuiltTrafficSeries {
|
): BuiltTrafficSeries {
|
||||||
const empty: BuiltTrafficSeries = {
|
const empty: BuiltTrafficSeries = {
|
||||||
rxNow: 0,
|
rxNow: 0,
|
||||||
@@ -113,11 +119,12 @@ export function buildTrafficFromSamples(
|
|||||||
}
|
}
|
||||||
if (rows.length === 0) return empty
|
if (rows.length === 0) return empty
|
||||||
|
|
||||||
const byIface = new Map<string, TrafficSampleLike[]>()
|
const bySeries = new Map<string, TrafficSampleLike[]>()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const arr = byIface.get(r.interfaceName) ?? []
|
const peer = r.peerPublicKey ?? ""
|
||||||
|
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
|
||||||
arr.push(r)
|
arr.push(r)
|
||||||
byIface.set(r.interfaceName, arr)
|
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowList = Array.isArray(onlyInterface)
|
const allowList = Array.isArray(onlyInterface)
|
||||||
@@ -132,12 +139,20 @@ export function buildTrafficFromSamples(
|
|||||||
let txBytesDelta = 0
|
let txBytesDelta = 0
|
||||||
let sessions = 0
|
let sessions = 0
|
||||||
|
|
||||||
for (const [name, arr] of byIface) {
|
for (const [key, arr] of bySeries) {
|
||||||
|
const sep = key.indexOf("\0")
|
||||||
|
const name = sep >= 0 ? key.slice(0, sep) : key
|
||||||
|
const peer = sep >= 0 ? key.slice(sep + 1) : ""
|
||||||
if (allowList) {
|
if (allowList) {
|
||||||
if (!allowList.includes(name)) continue
|
if (!allowList.includes(name)) continue
|
||||||
} else if (isLoopbackName(name)) {
|
} else if (isLoopbackName(name)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (peerPublicKey === undefined) {
|
||||||
|
if (peer !== "") continue
|
||||||
|
} else if (peer !== peerPublicKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||||
const last = sorted[sorted.length - 1]
|
const last = sorted[sorted.length - 1]
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface BoundIfaceTrafficDto {
|
|||||||
userName: string
|
userName: string
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
interfaceType: string
|
interfaceType: string
|
||||||
|
peerPublicKey: string
|
||||||
|
peerName: string
|
||||||
comment: string
|
comment: string
|
||||||
serverId: string
|
serverId: string
|
||||||
serverName: string
|
serverName: string
|
||||||
@@ -77,22 +79,6 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
|
|||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
const parts: BuiltTrafficSeries[] = []
|
const parts: BuiltTrafficSeries[] = []
|
||||||
const interfaces: BoundIfaceTrafficDto[] = []
|
const interfaces: BoundIfaceTrafficDto[] = []
|
||||||
const byServer = new Map<number, string[]>()
|
|
||||||
for (const b of user.bindings) {
|
|
||||||
const arr = byServer.get(b.serverId) ?? []
|
|
||||||
arr.push(b.interfaceName)
|
|
||||||
byServer.set(b.serverId, arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [serverId, names] of byServer) {
|
|
||||||
let rows = sampleCache.get(serverId)
|
|
||||||
if (!rows) {
|
|
||||||
rows = readServerSamplesInRange(serverId, sinceIso)
|
|
||||||
sampleCache.set(serverId, rows)
|
|
||||||
}
|
|
||||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, names)
|
|
||||||
parts.push(built)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const b of user.bindings) {
|
for (const b of user.bindings) {
|
||||||
let rows = sampleCache.get(b.serverId)
|
let rows = sampleCache.get(b.serverId)
|
||||||
@@ -100,19 +86,25 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
|
|||||||
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
||||||
sampleCache.set(b.serverId, rows)
|
sampleCache.set(b.serverId, rows)
|
||||||
}
|
}
|
||||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName)
|
const peerKey = b.peerPublicKey ?? ""
|
||||||
const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)]
|
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
|
||||||
|
parts.push(built)
|
||||||
|
const last = [...rows.filter((r) =>
|
||||||
|
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
|
||||||
|
)]
|
||||||
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
||||||
.at(-1)
|
.at(-1)
|
||||||
const running = Boolean(last?.running) && !last?.disabled
|
const running = Boolean(last?.running) && !last?.disabled
|
||||||
interfaces.push({
|
interfaces.push({
|
||||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
|
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
|
||||||
bindingId: b.id,
|
bindingId: b.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userLogin: user.login,
|
userLogin: user.login,
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: peerKey,
|
||||||
|
peerName: b.peerName ?? "",
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
serverId: String(b.serverId),
|
serverId: String(b.serverId),
|
||||||
serverName: b.serverName,
|
serverName: b.serverName,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { generateKeyPairSync } from "node:crypto"
|
||||||
|
|
||||||
|
/** WireGuard Curve25519 keypair as RouterOS/wg-quick base64 (32 bytes). */
|
||||||
|
export function generateWireGuardKeyPair(): { publicKey: string; privateKey: string } {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync("x25519")
|
||||||
|
const pubDer = publicKey.export({ type: "spki", format: "der" })
|
||||||
|
const privDer = privateKey.export({ type: "pkcs8", format: "der" })
|
||||||
|
return {
|
||||||
|
publicKey: Buffer.from(pubDer.subarray(-32)).toString("base64"),
|
||||||
|
privateKey: Buffer.from(privDer.subarray(-32)).toString("base64"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,4 +211,51 @@ export function getEnabledServerById(serverId: string | number): ServerRow | nul
|
|||||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CatalogWgPeer = {
|
||||||
|
interfaceName: string
|
||||||
|
publicKey: string
|
||||||
|
name: string
|
||||||
|
comment: string
|
||||||
|
allowedIps: string[]
|
||||||
|
latestHandshake?: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WG_CATALOG_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
|
||||||
|
peers: CatalogWgPeer[]
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const row = getEnabledServerById(serverId)
|
||||||
|
if (!row) return { peers: [], error: "Сервер не найден" }
|
||||||
|
try {
|
||||||
|
const client = MikrotikClient.fromServer(row)
|
||||||
|
const peersRaw = await Promise.race([
|
||||||
|
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
|
||||||
|
const mapped = mapPeer(p, idx)
|
||||||
|
const interfaceName = (p.interface ?? "").trim()
|
||||||
|
const publicKey = mapped.publicKey.trim()
|
||||||
|
if (!interfaceName || !publicKey) return []
|
||||||
|
return [{
|
||||||
|
interfaceName,
|
||||||
|
publicKey,
|
||||||
|
name: mapped.name ?? "",
|
||||||
|
comment: mapped.comment ?? "",
|
||||||
|
allowedIps: mapped.allowedIps,
|
||||||
|
latestHandshake: mapped.latestHandshake,
|
||||||
|
disabled: mapped.disabled === true,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
return { peers }
|
||||||
|
} catch (e) {
|
||||||
|
return { peers: [], error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { type RosWireGuard, type RosWireGuardPeer }
|
export { type RosWireGuard, type RosWireGuardPeer }
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MikrotikClient } from "./mikrotik.js"
|
||||||
|
|
||||||
|
/** Общие PUT iface / peer / address для `/wireguard` и traffic-flow overlay. */
|
||||||
|
export function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (v !== undefined && v !== "") out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRosArray<T>(raw: unknown): T[] {
|
||||||
|
if (Array.isArray(raw)) return raw as T[]
|
||||||
|
if (raw && typeof raw === "object") return [raw as T]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rosRowId(row: Record<string, unknown>): string {
|
||||||
|
return String(row[".id"] ?? row.id ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putWireguardInterface(
|
||||||
|
client: MikrotikClient,
|
||||||
|
fields: Record<string, string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put("/interface/wireguard", toRosBody(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putIpAddress(
|
||||||
|
client: MikrotikClient,
|
||||||
|
address: string,
|
||||||
|
iface: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put("/ip/address", { address, interface: iface })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putWireguardPeer(
|
||||||
|
client: MikrotikClient,
|
||||||
|
fields: Record<string, string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put("/interface/wireguard/peers", toRosBody(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchRosPath(
|
||||||
|
client: MikrotikClient,
|
||||||
|
path: string,
|
||||||
|
fields: Record<string, string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.patch(path, toRosBody(fields))
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||||
|
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||||
|
return `${n} Б`
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
||||||
|
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "serverName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||||
|
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "src",
|
||||||
|
accessorKey: "src",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{row.original.src}
|
||||||
|
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dst",
|
||||||
|
accessorKey: "dst",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{row.original.dst}
|
||||||
|
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proto",
|
||||||
|
accessorKey: "protoName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Proto</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rate",
|
||||||
|
accessorFn: (r) => r.bps,
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
accessorKey: "bytes",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "inIface",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
emptyMessage="Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TrafficFlowsDataGrid }
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type AppUser,
|
type AppUser,
|
||||||
type InterfaceType,
|
type InterfaceType,
|
||||||
} from "@/lib/users"
|
} from "@/lib/users"
|
||||||
import { CableIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||||
|
|
||||||
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||||
ether: "outline",
|
ether: "outline",
|
||||||
@@ -87,23 +87,29 @@ function UsersExpandedDetail({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||||
{items.map((b) => {
|
{items.map((b) => {
|
||||||
const meta = TYPE_ICON[b.interfaceType]
|
const meta = TYPE_ICON[b.interfaceType]
|
||||||
const Icon = meta.icon
|
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
|
||||||
|
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", meta.className)}>
|
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
|
||||||
<Icon />
|
<Icon />
|
||||||
</IconTile>
|
</IconTile>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-mono font-medium leading-tight truncate">
|
<p className="text-xs font-mono font-medium leading-tight truncate">
|
||||||
{b.interfaceName}
|
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
|
||||||
|
? `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||||
|
: b.interfaceName}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
||||||
{IFACE_TYPE_LABEL[b.interfaceType]}
|
{IFACE_TYPE_LABEL[b.interfaceType]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||||
|
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||||
|
) : null}
|
||||||
{b.comment ? (
|
{b.comment ? (
|
||||||
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||||
{b.comment}
|
{b.comment}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { FormField } from "@/components/form-kit"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { CopyIcon } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||||
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
|
function FlowOverlaySheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
servers,
|
||||||
|
backendUrl,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
servers: ServerRead[]
|
||||||
|
backendUrl: string
|
||||||
|
onDone?: (result: TrafficFlowOverlayResult) => void
|
||||||
|
}) {
|
||||||
|
const jumpHosts = useMemo(
|
||||||
|
() => servers.filter((s) => s.enabled && s.type === "jump-host"),
|
||||||
|
[servers],
|
||||||
|
)
|
||||||
|
const [serverId, setServerId] = useState("")
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setResult(null)
|
||||||
|
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
|
||||||
|
}, [open, jumpHosts])
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!serverId) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await applyTrafficFlowOverlay(backendUrl, serverId)
|
||||||
|
setResult(res)
|
||||||
|
toast.success(`wg-flow на ${res.address}`)
|
||||||
|
onDone?.(res)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<SheetTitle>Подключить jump-host</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Создать wg-flow на выбранном MikroTik и направить Traffic Flow на collector MM. Хост Docker уже должен слушать WireGuard.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
<FormField label="Jump-host" required>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
|
||||||
|
value={serverId}
|
||||||
|
onChange={(e) => setServerId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Выберите сервер…</option>
|
||||||
|
{jumpHosts.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name || s.host} ({s.host})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{result ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Пир для хоста MM (`wg set` или допишите conf):</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(result.linuxPeerBlock)
|
||||||
|
toast.success("Скопировано")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-3.5" />
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-[11px] font-mono bg-muted/40 border rounded-md p-3 whitespace-pre-wrap">{result.linuxPeerBlock}</pre>
|
||||||
|
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
||||||
|
{result.steps.map((s) => (
|
||||||
|
<li key={s}>{s}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
|
||||||
|
<Button disabled={!serverId || busy} onClick={() => { void handleSubmit() }}>
|
||||||
|
{busy ? "Подключение…" : "Подключить"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FlowOverlaySheet }
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"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 {
|
||||||
|
generateTrafficFlowKeys,
|
||||||
|
getTrafficFlowHostFiles,
|
||||||
|
getTrafficFlowSettings,
|
||||||
|
putTrafficFlowSettings,
|
||||||
|
} from "@/shared/api/traffic-flow"
|
||||||
|
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 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 [ingestOn, setIngestOn] = 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))
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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 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>
|
||||||
|
Приватный ключ хранится в SQLite панели, не коммитьте его. Порт 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>
|
||||||
|
|
||||||
|
<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} onClick={() => { void handleSave() }}>
|
||||||
|
Сохранить NetFlow
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
|
||||||
|
<KeyRoundIcon className="size-4" />
|
||||||
|
Ключи хоста
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
wg-quick / compose / firewall
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<CodeExportSheet
|
||||||
|
open={exportOpen}
|
||||||
|
onClose={() => setExportOpen(false)}
|
||||||
|
title="Файлы для хоста Docker MM"
|
||||||
|
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||||
|
formats={formats}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NetflowSettingsPanel }
|
||||||
+219
-49
@@ -19,12 +19,23 @@ import {
|
|||||||
StepperTrigger,
|
StepperTrigger,
|
||||||
} from "@/components/reui/stepper"
|
} from "@/components/reui/stepper"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { listInterfaceCatalog } from "@/shared/api/users"
|
import { listInterfaceCatalog } from "@/shared/api/users"
|
||||||
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
||||||
import {
|
import {
|
||||||
|
bindingDiffKey,
|
||||||
|
bindingTitle,
|
||||||
catalogForServer,
|
catalogForServer,
|
||||||
defaultSections,
|
defaultSections,
|
||||||
defaultServers,
|
defaultServers,
|
||||||
@@ -43,10 +54,28 @@ import {
|
|||||||
type UserServerOption,
|
type UserServerOption,
|
||||||
} from "@/lib/users"
|
} from "@/lib/users"
|
||||||
import {
|
import {
|
||||||
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
|
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
|
||||||
TrashIcon, WrenchIcon,
|
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const IFACE_TILE: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
|
||||||
|
ether: { icon: CableIcon, className: "text-muted-foreground" },
|
||||||
|
gre: { icon: NetworkIcon, className: "text-info" },
|
||||||
|
wg: { icon: ShieldIcon, className: "text-success" },
|
||||||
|
other: { icon: CableIcon, className: "text-muted-foreground" },
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogPick = {
|
||||||
|
interfaceName: string
|
||||||
|
peerPublicKey: string
|
||||||
|
peerName?: string
|
||||||
|
type: InterfaceType
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickKey(p: CatalogPick): string {
|
||||||
|
return `${p.interfaceName}\0${p.peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
|
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
|
||||||
"Обзор": <LayoutDashboardIcon className="size-3" />,
|
"Обзор": <LayoutDashboardIcon className="size-3" />,
|
||||||
"Данные": <EyeIcon className="size-3" />,
|
"Данные": <EyeIcon className="size-3" />,
|
||||||
@@ -124,7 +153,8 @@ function UserSheet({
|
|||||||
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
||||||
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
||||||
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
||||||
const [selectedNames, setSelectedNames] = useState<string[]>([])
|
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
|
||||||
|
const [expandedWg, setExpandedWg] = useState<string | null>(null)
|
||||||
const [newComment, setNewComment] = useState("")
|
const [newComment, setNewComment] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,7 +163,8 @@ function UserSheet({
|
|||||||
setSheetStep(1)
|
setSheetStep(1)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
setCatalogServerId(servers[0]?.id ?? "")
|
setCatalogServerId(servers[0]?.id ?? "")
|
||||||
setSelectedNames([])
|
setSelectedPicks([])
|
||||||
|
setExpandedWg(null)
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}, [open, user, servers])
|
}, [open, user, servers])
|
||||||
|
|
||||||
@@ -212,29 +243,32 @@ function UserSheet({
|
|||||||
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
||||||
|
|
||||||
const addSelectedBindings = () => {
|
const addSelectedBindings = () => {
|
||||||
if (!catalogSrv || selectedNames.length === 0) return
|
if (!catalogSrv || selectedPicks.length === 0) return
|
||||||
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
|
const existing = new Set(form.bindings.map(bindingDiffKey))
|
||||||
const next: InterfaceBinding[] = [...form.bindings]
|
const next: InterfaceBinding[] = [...form.bindings]
|
||||||
for (const name of selectedNames) {
|
for (const pick of selectedPicks) {
|
||||||
const key = `${catalogSrv.id}::${name}`
|
const key = bindingDiffKey({
|
||||||
|
serverId: catalogSrv.id,
|
||||||
|
interfaceName: pick.interfaceName,
|
||||||
|
peerPublicKey: pick.peerPublicKey,
|
||||||
|
})
|
||||||
if (existing.has(key)) continue
|
if (existing.has(key)) continue
|
||||||
const iface = catalog.find((c) => c.name === name)
|
|
||||||
if (!iface) continue
|
|
||||||
if (iface.boundUserId && iface.boundUserId !== user?.id) continue
|
|
||||||
next.push({
|
next.push({
|
||||||
id: `pending-${catalogSrv.id}-${name}`,
|
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
|
||||||
userId: user?.id ?? "",
|
userId: user?.id ?? "",
|
||||||
serverId: catalogSrv.id,
|
serverId: catalogSrv.id,
|
||||||
serverName: catalogSrv.name,
|
serverName: catalogSrv.name,
|
||||||
serverSite: catalogSrv.site,
|
serverSite: catalogSrv.site,
|
||||||
serverCountry: catalogSrv.country,
|
serverCountry: catalogSrv.country,
|
||||||
interfaceName: name,
|
interfaceName: pick.interfaceName,
|
||||||
interfaceType: iface.type,
|
interfaceType: pick.type,
|
||||||
|
peerPublicKey: pick.peerPublicKey || undefined,
|
||||||
|
peerName: pick.peerName,
|
||||||
comment: newComment.trim(),
|
comment: newComment.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setForm((f) => ({ ...f, bindings: next }))
|
setForm((f) => ({ ...f, bindings: next }))
|
||||||
setSelectedNames([])
|
setSelectedPicks([])
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,15 +276,26 @@ function UserSheet({
|
|||||||
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleName = (name: string) => {
|
const togglePick = (pick: CatalogPick) => {
|
||||||
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
|
setSelectedPicks((prev) => {
|
||||||
|
const key = pickKey(pick)
|
||||||
|
return prev.some((p) => pickKey(p) === key)
|
||||||
|
? prev.filter((p) => pickKey(p) !== key)
|
||||||
|
: [...prev, pick]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const alreadyBoundHere = useMemo(
|
const alreadyBoundHere = useMemo(
|
||||||
() => new Set(form.bindings.filter((b) => b.serverId === catalogServerId).map((b) => b.interfaceName)),
|
() => new Set(
|
||||||
|
form.bindings
|
||||||
|
.filter((b) => b.serverId === catalogServerId)
|
||||||
|
.map((b) => `${b.interfaceName}\0${b.peerPublicKey ?? ""}`),
|
||||||
|
),
|
||||||
[form.bindings, catalogServerId],
|
[form.bindings, catalogServerId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
@@ -411,7 +456,7 @@ function UserSheet({
|
|||||||
|
|
||||||
<StepperContent value={4} className="flex flex-col">
|
<StepperContent value={4} className="flex flex-col">
|
||||||
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
|
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
|
||||||
Привязка интерфейсов сервера. Один интерфейс — один пользователь.
|
Ethernet и GRE — целиком. WireGuard — только пир (public-key).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="py-3 flex flex-col gap-3 border-b">
|
<div className="py-3 flex flex-col gap-3 border-b">
|
||||||
@@ -419,56 +464,178 @@ function UserSheet({
|
|||||||
<select
|
<select
|
||||||
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
||||||
value={catalogServerId}
|
value={catalogServerId}
|
||||||
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
|
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
|
||||||
>
|
>
|
||||||
{servers.map((s) => (
|
{servers.map((s) => (
|
||||||
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5 max-h-48 overflow-y-auto">
|
<Frame dense spacing="sm">
|
||||||
|
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
|
||||||
{catalog.length === 0 && (
|
{catalog.length === 0 && (
|
||||||
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
|
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
{catalog.map((iface) => {
|
{catalog.map((iface) => {
|
||||||
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
const tile = IFACE_TILE[iface.type]
|
||||||
const mine = alreadyBoundHere.has(iface.name)
|
const Icon = tile.icon
|
||||||
const disabled = taken || mine
|
if (iface.type === "wg") {
|
||||||
|
const open = expandedWg === iface.name
|
||||||
|
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||||
|
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||||
return (
|
return (
|
||||||
<label
|
<div key={iface.name} className="flex flex-col gap-0.5">
|
||||||
key={iface.name}
|
<Item
|
||||||
|
size="xs"
|
||||||
|
variant={open ? "muted" : "default"}
|
||||||
|
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
|
||||||
|
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
|
||||||
|
>
|
||||||
|
<ItemMedia>
|
||||||
|
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0">
|
||||||
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||||
|
<span className="min-w-0 truncate">{iface.name}</span>
|
||||||
|
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
{legacyMine ? (
|
||||||
|
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
|
||||||
|
) : null}
|
||||||
|
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
{open ? (
|
||||||
|
<div className="ml-4 flex flex-col gap-0.5 border-l pl-2">
|
||||||
|
{iface.peersError ? (
|
||||||
|
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Не удалось загрузить пиры</p>
|
||||||
|
) : null}
|
||||||
|
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
|
||||||
|
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
|
||||||
|
) : null}
|
||||||
|
{(iface.peers ?? []).map((peer) => {
|
||||||
|
const pick: CatalogPick = {
|
||||||
|
interfaceName: iface.name,
|
||||||
|
peerPublicKey: peer.publicKey,
|
||||||
|
peerName: peer.name,
|
||||||
|
type: "wg",
|
||||||
|
}
|
||||||
|
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
|
||||||
|
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
|
||||||
|
const disabled = taken || mine || legacyTaken
|
||||||
|
const selected = selectedPickKeys.has(pickKey(pick))
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={peer.publicKey}
|
||||||
|
size="xs"
|
||||||
|
variant={selected ? "muted" : "default"}
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => togglePick(pick)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 py-0.5 text-xs",
|
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
selected && "ring-1 ring-border",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<ItemMedia>
|
||||||
type="checkbox"
|
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
|
||||||
disabled={disabled}
|
<KeyRoundIcon />
|
||||||
checked={selectedNames.includes(iface.name)}
|
</IconTile>
|
||||||
onChange={() => toggleName(iface.name)}
|
</ItemMedia>
|
||||||
className="rounded border-input accent-primary"
|
<ItemContent className="min-w-0">
|
||||||
/>
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||||
<span className="font-mono">{iface.name}</span>
|
<span className="min-w-0 truncate">{peer.name || peer.publicKey}</span>
|
||||||
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
|
{peer.latestHandshake ? (
|
||||||
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
|
<Badge variant="success-light" size="xs">handshake</Badge>
|
||||||
</Badge>
|
) : null}
|
||||||
{taken && (
|
</ItemTitle>
|
||||||
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
|
</ItemContent>
|
||||||
)}
|
<ItemActions>
|
||||||
{mine && !taken && (
|
{taken ? (
|
||||||
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
|
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{peer.boundUserLogin}</span>
|
||||||
)}
|
) : mine ? (
|
||||||
</label>
|
<span className="text-[10px] text-muted-foreground">уже привязан</span>
|
||||||
|
) : selected ? (
|
||||||
|
<Badge variant="secondary" size="xs">Выбран</Badge>
|
||||||
|
) : null}
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type }
|
||||||
|
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||||
|
const mine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||||
|
const disabled = taken || mine
|
||||||
|
const selected = selectedPickKeys.has(pickKey(pick))
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={iface.name}
|
||||||
|
size="xs"
|
||||||
|
variant={selected ? "muted" : "default"}
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => togglePick(pick)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||||
|
selected && "ring-1 ring-border",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia>
|
||||||
|
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0">
|
||||||
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||||
|
{iface.running ? <StatusDot status="online" /> : <StatusDot status="offline" />}
|
||||||
|
<span className="min-w-0 truncate">{iface.name}</span>
|
||||||
|
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
|
||||||
|
{IFACE_TYPE_LABEL[iface.type]}
|
||||||
|
</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
{taken ? (
|
||||||
|
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{iface.boundUserLogin}</span>
|
||||||
|
) : mine ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">уже привязан</span>
|
||||||
|
) : selected ? (
|
||||||
|
<Badge variant="secondary" size="xs">Выбран</Badge>
|
||||||
|
) : null}
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
placeholder="Комментарий (необязательно)"
|
placeholder="Комментарий (необязательно)"
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" disabled={selectedNames.length === 0} onClick={addSelectedBindings}>
|
<Button size="sm" disabled={selectedPicks.length === 0} onClick={addSelectedBindings}>
|
||||||
<PlusIcon className="size-3.5" />Привязать
|
<PlusIcon className="size-3.5" />Привязать
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,8 +647,11 @@ function UserSheet({
|
|||||||
{form.bindings.map((b) => (
|
{form.bindings.map((b) => (
|
||||||
<div key={b.id} className="flex items-center gap-2 py-1">
|
<div key={b.id} className="flex items-center gap-2 py-1">
|
||||||
<Flag code={b.serverCountry} size={12} />
|
<Flag code={b.serverCountry} size={12} />
|
||||||
<span className="font-mono text-xs truncate">{b.interfaceName}</span>
|
<span className="font-mono text-xs truncate">{bindingTitle(b)}</span>
|
||||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
|
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
|
||||||
|
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||||
|
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||||
|
) : null}
|
||||||
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
|
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
# IPFIX: только на WG-адресе хоста после `wg-quick up wg-flow`, не 0.0.0.0
|
||||||
|
# - "10.255.254.1:4739:4739/udp"
|
||||||
environment:
|
environment:
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
||||||
|
# Внутри контейнера слушаем все интерфейсы; на хосте UDP 4739 публикуется только на WG-IP
|
||||||
|
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/mmapp/data:/app/data
|
- /opt/mmapp/data:/app/data
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
+78
-13
@@ -15,9 +15,32 @@ export interface InterfaceBinding {
|
|||||||
serverCountry: string
|
serverCountry: string
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
interfaceType: InterfaceType
|
interfaceType: InterfaceType
|
||||||
|
peerPublicKey?: string
|
||||||
|
peerName?: string
|
||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogPeer {
|
||||||
|
publicKey: string
|
||||||
|
name: string
|
||||||
|
comment: string
|
||||||
|
allowedIps: string[]
|
||||||
|
latestHandshake?: string
|
||||||
|
boundUserId: string | null
|
||||||
|
boundUserLogin: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogIface {
|
||||||
|
name: string
|
||||||
|
type: InterfaceType
|
||||||
|
running: boolean
|
||||||
|
disabled: boolean
|
||||||
|
boundUserId: string | null
|
||||||
|
boundUserLogin: string | null
|
||||||
|
peers?: CatalogPeer[]
|
||||||
|
peersError?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppUserForm {
|
export interface AppUserForm {
|
||||||
name: string
|
name: string
|
||||||
login: string
|
login: string
|
||||||
@@ -43,15 +66,6 @@ export interface AppUser {
|
|||||||
bindings: InterfaceBinding[]
|
bindings: InterfaceBinding[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogIface {
|
|
||||||
name: string
|
|
||||||
type: InterfaceType
|
|
||||||
running: boolean
|
|
||||||
disabled: boolean
|
|
||||||
boundUserId: string | null
|
|
||||||
boundUserLogin: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserServerOption {
|
export interface UserServerOption {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -138,15 +152,37 @@ export const MOCK_IFACE_CATALOG: Record<string, CatalogIface[]> = {
|
|||||||
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "[email protected]" },
|
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "[email protected]" },
|
||||||
{ name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{
|
||||||
|
name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
|
||||||
|
peers: [
|
||||||
|
{ publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak", comment: "", allowedIps: ["10.8.0.2/32"], boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
|
{ publicKey: "mockPeerKeyBBBB0123456789", name: "laptop-ak", comment: "", allowedIps: ["10.8.0.3/32"], boundUserId: null, boundUserLogin: null, latestHandshake: "12s" },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
srv7: [
|
srv7: [
|
||||||
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
|
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
|
||||||
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
||||||
{ name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
|
{
|
||||||
|
name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
|
||||||
|
peers: [
|
||||||
|
{ publicKey: "mockPeerKeyLABB0123456789", name: "lab-peer", comment: "", allowedIps: ["10.9.0.2/32"], boundUserId: null, boundUserLogin: null },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindingDiffKey(b: Pick<InterfaceBinding, "serverId" | "interfaceName" | "peerPublicKey">): string {
|
||||||
|
return `${b.serverId}::${b.interfaceName}::${b.peerPublicKey ?? ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindingTitle(b: Pick<InterfaceBinding, "interfaceName" | "interfaceType" | "peerName" | "peerPublicKey">): string {
|
||||||
|
if (b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)) {
|
||||||
|
return `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||||
|
}
|
||||||
|
return b.interfaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
function bind(
|
function bind(
|
||||||
@@ -156,6 +192,7 @@ function bind(
|
|||||||
interfaceName: string,
|
interfaceName: string,
|
||||||
interfaceType: InterfaceType,
|
interfaceType: InterfaceType,
|
||||||
comment: string,
|
comment: string,
|
||||||
|
peer?: { publicKey: string; name: string },
|
||||||
): InterfaceBinding {
|
): InterfaceBinding {
|
||||||
const srv = servers.find((s) => s.id === serverId)
|
const srv = servers.find((s) => s.id === serverId)
|
||||||
return {
|
return {
|
||||||
@@ -167,6 +204,8 @@ function bind(
|
|||||||
serverCountry: srv?.country ?? "UN",
|
serverCountry: srv?.country ?? "UN",
|
||||||
interfaceName,
|
interfaceName,
|
||||||
interfaceType,
|
interfaceType,
|
||||||
|
peerPublicKey: peer?.publicKey,
|
||||||
|
peerName: peer?.name,
|
||||||
comment,
|
comment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +219,7 @@ export const INIT_USERS: AppUser[] = [
|
|||||||
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
|
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
|
||||||
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
|
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
|
||||||
bind("b3", "u1", "srv1", "gre-datacenter", "gre", "ЦОД"),
|
bind("b3", "u1", "srv1", "gre-datacenter", "gre", "ЦОД"),
|
||||||
bind("b4", "u1", "srv1", "wg-msk-spb", "wg", "Overlay SPB"),
|
bind("b4", "u1", "srv1", "wg-msk-spb", "wg", "Overlay SPB", { publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak" }),
|
||||||
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
|
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
|
||||||
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
|
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
|
||||||
],
|
],
|
||||||
@@ -213,8 +252,34 @@ export const INIT_USERS: AppUser[] = [
|
|||||||
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
|
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
|
||||||
const base = MOCK_IFACE_CATALOG[serverId] ?? []
|
const base = MOCK_IFACE_CATALOG[serverId] ?? []
|
||||||
return base.map((iface) => {
|
return base.map((iface) => {
|
||||||
|
if (iface.type === "wg") {
|
||||||
|
const peers = (iface.peers ?? []).map((peer) => {
|
||||||
const owner = users.find((u) =>
|
const owner = users.find((u) =>
|
||||||
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name),
|
u.bindings.some((b) =>
|
||||||
|
b.serverId === serverId
|
||||||
|
&& b.interfaceName === iface.name
|
||||||
|
&& (b.peerPublicKey ?? "") === peer.publicKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!owner) return { ...peer, boundUserId: null, boundUserLogin: null }
|
||||||
|
return { ...peer, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
||||||
|
})
|
||||||
|
const legacy = users.find((u) =>
|
||||||
|
u.bindings.some((b) =>
|
||||||
|
b.serverId === serverId
|
||||||
|
&& b.interfaceName === iface.name
|
||||||
|
&& !(b.peerPublicKey ?? ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...iface,
|
||||||
|
boundUserId: legacy?.id ?? null,
|
||||||
|
boundUserLogin: legacy ? (legacy.email || legacy.login) : null,
|
||||||
|
peers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const owner = users.find((u) =>
|
||||||
|
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name && !(b.peerPublicKey ?? "")),
|
||||||
)
|
)
|
||||||
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
|
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
|
||||||
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
"./users": {
|
"./users": {
|
||||||
"types": "./dist/users.d.ts",
|
"types": "./dist/users.d.ts",
|
||||||
"default": "./dist/users.js"
|
"default": "./dist/users.js"
|
||||||
|
},
|
||||||
|
"./traffic-flow": {
|
||||||
|
"types": "./dist/traffic-flow.d.ts",
|
||||||
|
"default": "./dist/traffic-flow.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from "./certificates.js"
|
|||||||
export * from "./backups.js"
|
export * from "./backups.js"
|
||||||
export * from "./wireguard.js"
|
export * from "./wireguard.js"
|
||||||
export * from "./users.js"
|
export * from "./users.js"
|
||||||
|
export * from "./traffic-flow.js"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const flowHostPeerSchema = z.object({
|
||||||
|
serverId: z.number().int().positive(),
|
||||||
|
name: z.string(),
|
||||||
|
publicKey: z.string().min(1),
|
||||||
|
allowedIps: z.array(z.string().min(1)).min(1),
|
||||||
|
address: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const trafficFlowSettingsDtoSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
collectorIp: z.string().min(1),
|
||||||
|
flowListenPort: z.number().int().positive(),
|
||||||
|
wgListenPort: z.number().int().positive(),
|
||||||
|
prefix: z.string().min(1),
|
||||||
|
publicEndpoint: z.string(),
|
||||||
|
hostPublicKey: z.string(),
|
||||||
|
hasHostPrivateKey: z.boolean(),
|
||||||
|
hubServerId: z.number().int().positive().nullable(),
|
||||||
|
retentionHours: z.number().int().positive(),
|
||||||
|
topN: z.number().int().positive(),
|
||||||
|
lastDatagramAt: z.string().nullable(),
|
||||||
|
lastExporterIp: z.string().nullable(),
|
||||||
|
lastError: z.string().nullable(),
|
||||||
|
packetsReceived: z.number().int().nonnegative(),
|
||||||
|
listenerBound: z.boolean(),
|
||||||
|
listenerAddress: z.string().nullable(),
|
||||||
|
peers: z.array(flowHostPeerSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const trafficFlowSettingsPatchSchema = z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
collectorIp: z.string().min(1).optional(),
|
||||||
|
flowListenPort: z.number().int().positive().optional(),
|
||||||
|
wgListenPort: z.number().int().positive().optional(),
|
||||||
|
prefix: z.string().min(1).optional(),
|
||||||
|
publicEndpoint: z.string().optional(),
|
||||||
|
hubServerId: z.number().int().positive().nullable().optional(),
|
||||||
|
retentionHours: z.number().int().positive().optional(),
|
||||||
|
topN: z.number().int().positive().max(1000).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const trafficFlowOverlayRequestSchema = z.object({
|
||||||
|
serverId: z.union([z.string(), z.number()]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const trafficFlowOverlayResultSchema = z.object({
|
||||||
|
ok: z.boolean(),
|
||||||
|
serverId: z.number().int(),
|
||||||
|
interfaceName: z.string(),
|
||||||
|
address: z.string(),
|
||||||
|
publicKey: z.string(),
|
||||||
|
linuxPeerBlock: z.string(),
|
||||||
|
trafficFlow: z.boolean(),
|
||||||
|
steps: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowTalkerDtoSchema = z.object({
|
||||||
|
serverId: z.string(),
|
||||||
|
serverName: z.string(),
|
||||||
|
src: z.string(),
|
||||||
|
dst: z.string(),
|
||||||
|
proto: z.number().int(),
|
||||||
|
protoName: z.string(),
|
||||||
|
srcPort: z.number().int(),
|
||||||
|
dstPort: z.number().int(),
|
||||||
|
bytes: z.number().nonnegative(),
|
||||||
|
packets: z.number().nonnegative(),
|
||||||
|
bps: z.number().nonnegative(),
|
||||||
|
inIface: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowStatsDtoSchema = z.object({
|
||||||
|
exportersOnline: z.number().int().nonnegative(),
|
||||||
|
bytesPerMin: z.number().nonnegative(),
|
||||||
|
uniqueSrc: z.number().int().nonnegative(),
|
||||||
|
uniqueDst: z.number().int().nonnegative(),
|
||||||
|
topProto: z.string(),
|
||||||
|
talkers: z.array(flowTalkerDtoSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FlowHostPeer = z.infer<typeof flowHostPeerSchema>
|
||||||
|
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
|
||||||
|
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
|
||||||
|
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
|
||||||
|
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
||||||
|
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
||||||
@@ -23,6 +23,8 @@ export const userBindingSchema = z.object({
|
|||||||
serverCountry: z.string(),
|
serverCountry: z.string(),
|
||||||
interfaceName: z.string().min(1),
|
interfaceName: z.string().min(1),
|
||||||
interfaceType: interfaceTypeSchema,
|
interfaceType: interfaceTypeSchema,
|
||||||
|
peerPublicKey: z.string().default(""),
|
||||||
|
peerName: z.string().default(""),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
@@ -71,6 +73,8 @@ export const userBindingCreateSchema = z.object({
|
|||||||
serverId: z.coerce.number().int().positive(),
|
serverId: z.coerce.number().int().positive(),
|
||||||
interfaceName: z.string().min(1),
|
interfaceName: z.string().min(1),
|
||||||
interfaceType: interfaceTypeSchema.optional(),
|
interfaceType: interfaceTypeSchema.optional(),
|
||||||
|
peerPublicKey: z.string().optional(),
|
||||||
|
peerName: z.string().optional(),
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,6 +82,16 @@ export const interfaceCatalogQuerySchema = z.object({
|
|||||||
serverId: z.coerce.number().int().positive(),
|
serverId: z.coerce.number().int().positive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const catalogPeerSchema = z.object({
|
||||||
|
publicKey: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
comment: z.string(),
|
||||||
|
allowedIps: z.array(z.string()),
|
||||||
|
latestHandshake: z.string().optional(),
|
||||||
|
boundUserId: z.string().nullable(),
|
||||||
|
boundUserLogin: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export const catalogInterfaceSchema = z.object({
|
export const catalogInterfaceSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
type: interfaceTypeSchema,
|
type: interfaceTypeSchema,
|
||||||
@@ -85,6 +99,8 @@ export const catalogInterfaceSchema = z.object({
|
|||||||
disabled: z.boolean(),
|
disabled: z.boolean(),
|
||||||
boundUserId: z.string().nullable(),
|
boundUserId: z.string().nullable(),
|
||||||
boundUserLogin: z.string().nullable(),
|
boundUserLogin: z.string().nullable(),
|
||||||
|
peers: z.array(catalogPeerSchema).optional(),
|
||||||
|
peersError: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const appUserListSchema = z.array(appUserReadSchema)
|
export const appUserListSchema = z.array(appUserReadSchema)
|
||||||
@@ -99,4 +115,5 @@ export type AppUserRead = z.infer<typeof appUserReadSchema>
|
|||||||
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
|
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
|
||||||
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
|
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
|
||||||
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
|
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
|
||||||
|
export type CatalogPeer = z.infer<typeof catalogPeerSchema>
|
||||||
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
|
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
FlowStatsDto,
|
||||||
|
TrafficFlowOverlayResult,
|
||||||
|
TrafficFlowSettingsDto,
|
||||||
|
TrafficFlowSettingsPatch,
|
||||||
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
|
export async function getTrafficFlowSettings(baseUrl: string): Promise<TrafficFlowSettingsDto> {
|
||||||
|
return requestJson<TrafficFlowSettingsDto>(baseUrl, "/api/traffic/flow/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putTrafficFlowSettings(
|
||||||
|
baseUrl: string,
|
||||||
|
patch: TrafficFlowSettingsPatch,
|
||||||
|
): Promise<{ ok: boolean; settings: TrafficFlowSettingsDto }> {
|
||||||
|
return requestJson(baseUrl, "/api/traffic/flow/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTrafficFlowKeys(baseUrl: string): Promise<{
|
||||||
|
ok: boolean
|
||||||
|
created: boolean
|
||||||
|
publicKey: string
|
||||||
|
settings: TrafficFlowSettingsDto
|
||||||
|
}> {
|
||||||
|
return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrafficFlowHostFile = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
filename: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> {
|
||||||
|
return requestJson(baseUrl, "/api/traffic/flow/host-files")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyTrafficFlowOverlay(
|
||||||
|
baseUrl: string,
|
||||||
|
serverId: string | number,
|
||||||
|
): Promise<TrafficFlowOverlayResult> {
|
||||||
|
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ serverId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
|
||||||
|
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ export function toFrontendBinding(b: UserBinding): InterfaceBinding {
|
|||||||
serverCountry: b.serverCountry,
|
serverCountry: b.serverCountry,
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey || undefined,
|
||||||
|
peerName: b.peerName || undefined,
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user