Compare commits

...
7 Commits
Author SHA1 Message Date
DenozordecandCursor 5e512407e5 fix(traffic): записывать потоки при подмене UDP-источника в Docker
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Пакеты IPFIX доходили, но разговоры отбрасывались, если Docker подменял адрес jump-host на 172.x.

Co-authored-by: Cursor <[email protected]>
2026-09-07 00:02:50 +07:00
DenozordecandCursor 13889005f8 refactor(traffic): update traffic flow host file generation and configuration scripts
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 3m2s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`.
- Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts.
- Updated the `generateNativeConf` function to conditionally include the listen port in the configuration.
- Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts.

Co-authored-by: Cursor <[email protected]>
2026-09-06 23:20:12 +07:00
DenozordecandCursor f0dc5acfd3 fix(traffic): сделать JH сервером WG и патчить traffic-flow по id
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 9s
RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232.

Co-authored-by: Cursor <[email protected]>
2026-09-06 22:58:31 +07:00
DenozordecandCursor 63bed28251 fix(ci): импортировать TrafficFlowHostFile в клиенте overlay
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 56s
Docker images / frontend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 16s
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:24:44 +07:00
DenozordecandCursor 95dcd3df58 fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
2026-09-06 22:16:41 +07:00
DenozordecandCursor 1e9312acbd feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 12s
Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <[email protected]>
2026-09-06 21:54:30 +07:00
Denozordec 5884bd8873 feat(traffic, users): enhance interface and peer management features
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
2026-09-06 20:45:37 +07:00
47 changed files with 3238 additions and 212 deletions
+3
View File
@@ -54,6 +54,7 @@ import {
type SchedulerJobGridRow,
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
@@ -1210,6 +1211,8 @@ export default function DataCollectionPage() {
</div>
</DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
+176 -40
View File
@@ -11,13 +11,20 @@ import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import {
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
import { useDataSource } from "@/lib/data-source"
import { useTrafficLive } from "@/hooks/use-traffic-live"
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 {
IFACE_TYPE_LABEL,
@@ -53,6 +60,8 @@ interface BoundIfaceTraffic {
userName: string
interfaceName: string
interfaceType: InterfaceType
peerPublicKey?: string
peerName?: string
comment: string
serverId: string
serverName: string
@@ -141,6 +150,11 @@ function hashSeed(s: string): number {
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[] {
return INIT_USERS.flatMap((u) =>
u.bindings.map((b) => {
@@ -149,13 +163,15 @@ function mockBoundFromUsers(): BoundIfaceTraffic[] {
const rxNow = offline ? 0 : 12 + (seed % 140)
const txNow = offline ? 0 : 8 + (seed % 110)
return {
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`,
bindingId: b.id,
userId: u.id,
userLogin: u.login,
userName: u.name,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
serverId: b.serverId,
serverName: b.serverName,
@@ -267,7 +283,7 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
"24h": "24ч",
}
type GroupMode = "servers" | "users" | "ifaces"
type GroupMode = "servers" | "users" | "ifaces" | "flows"
type SortField = "rx" | "tx" | "name" | "sessions"
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">
<StatusDot status={c.status} />
<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>
</div>
</div>
@@ -435,7 +451,7 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
<StatusDot status={c.status} />
<div className="flex-1 min-w-0">
<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>
{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">
@@ -652,7 +668,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
<DetailHeader range={range} setRange={setRange}>
<StatusDot status={sel.status} />
<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>
</div>
<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: "users", icon: <UsersIcon 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[] }> = [
@@ -722,6 +739,9 @@ export default function TrafficPage() {
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
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 { sample: liveSample, error: liveStreamError } = useTrafficLive({
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
@@ -812,6 +832,30 @@ export default function TrafficPage() {
void loadLiveTraffic(range)
}, [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 activeUserTraffic = isLive ? liveUsers : userTraffic
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
@@ -854,7 +898,7 @@ export default function TrafficPage() {
setGroupMode(next)
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
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")
setSortDir("desc")
setSearch("")
@@ -897,6 +941,7 @@ export default function TrafficPage() {
return [...activeBoundIfaces]
.filter(c => !q
|| c.interfaceName.toLowerCase().includes(q)
|| (c.peerName ?? "").toLowerCase().includes(q)
|| c.comment.toLowerCase().includes(q)
|| c.userLogin.toLowerCase().includes(q))
.sort((a, b) => {
@@ -925,6 +970,68 @@ export default function TrafficPage() {
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 (
<div className="flex flex-col h-full">
<PageHeader
@@ -934,7 +1041,10 @@ export default function TrafficPage() {
<Button
variant="outline"
size="sm"
onClick={() => { void loadLiveTraffic(range) }}
onClick={() => {
if (effectiveMode === "flows") void loadFlows()
else void loadLiveTraffic(range)
}}
disabled={isLive && liveBusy}
>
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
@@ -965,40 +1075,65 @@ export default function TrafficPage() {
<div className="flex flex-col gap-5">
<KpiStatGrid
aria-label="Сводка трафика"
items={[
{
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",
},
]}
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
{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 ?? []}
emptyHint={
flowStats?.packetsReceived
? (flowStats.lastError
|| `IPFIX приходит (${flowStats.lastExporterIp ?? "экспортёр"}), но разговоры ещё не записаны.`)
: undefined
}
/>
</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">
{/* ── left panel ── */}
<div className="flex flex-col gap-3">
@@ -1117,6 +1252,7 @@ export default function TrafficPage() {
</Frame>
</div>
)}
</div>
</div>
</div>
+7 -4
View File
@@ -23,6 +23,7 @@ import { useDataSource } from "@/lib/data-source"
import {
ALL_SECTIONS,
INIT_USERS,
bindingDiffKey,
userInitials,
type AppUser,
type AppUserForm,
@@ -106,19 +107,21 @@ export default function UsersPage() {
const activeCount = users.filter((u) => u.active).length
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map((b) => `${b.serverId}::${b.interfaceName}`))
const prevKeys = new Map(prev.map((b) => [`${b.serverId}::${b.interfaceName}`, b] as const))
const nextKeys = new Set(next.map(bindingDiffKey))
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
for (const b of prev) {
if (!nextKeys.has(`${b.serverId}::${b.interfaceName}`)) {
if (!nextKeys.has(bindingDiffKey(b))) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(`${b.serverId}::${b.interfaceName}`)) {
if (!prevKeys.has(bindingDiffKey(b))) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
})
}
+1
View File
@@ -14,6 +14,7 @@
"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:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
},
"dependencies": {
+114 -1
View File
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TEXT NOT NULL,
rx_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
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 (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
@@ -533,18 +575,80 @@ CREATE TABLE IF NOT EXISTS user_interface_bindings (
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)
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
ON user_interface_bindings(user_id);
`)
// 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 hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
@@ -611,6 +715,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
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 }>
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);
`)
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(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
+50 -1
View File
@@ -32,6 +32,8 @@ export const servers = sqliteTable("servers", {
lanSubnet: text("lan_subnet").notNull().default(""),
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
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'))`),
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) ──────────────────────
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", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
peerPublicKey: text("peer_public_key").notNull().default(""),
sampledAt: text("sampled_at").notNull(),
rxBytes: integer("rx_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"] })
.notNull()
.default("other"),
peerPublicKey: text("peer_public_key").notNull().default(""),
peerName: text("peer_name").notNull().default(""),
comment: text("comment").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
}, (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", {
@@ -592,6 +639,8 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$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 TrafficSampleRow = typeof trafficSamples.$inferSelect
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
+5
View File
@@ -10,6 +10,7 @@ import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import trafficFlowRoutes from "./routes/traffic-flow.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
@@ -27,6 +28,7 @@ import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
export async function buildApp(opts?: {
logger?: boolean
@@ -93,6 +95,7 @@ export async function buildApp(opts?: {
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(trafficFlowRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
@@ -112,8 +115,10 @@ export async function buildApp(opts?: {
if (opts?.startScheduler !== false) {
refreshScheduler()
startTrafficFlowListener()
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
})
}
+30 -2
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"
import Database from "better-sqlite3"
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
const sqlite = new Database(":memory:")
sqlite.pragma("foreign_keys = ON")
@@ -29,12 +30,14 @@ CREATE TABLE user_interface_bindings (
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)
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()
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")
+44
View File
@@ -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]
}
export function getBindingByServerIface(
export function getBindingByServerIfacePeer(
serverId: number,
interfaceName: string,
peerPublicKey = "",
): BindingRow | undefined {
return db
.select()
@@ -56,6 +57,7 @@ export function getBindingByServerIface(
.where(and(
eq(userInterfaceBindings.serverId, serverId),
eq(userInterfaceBindings.interfaceName, interfaceName),
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
))
.limit(1)
.all()[0]
@@ -18,7 +18,7 @@ import {
createUserRow,
deleteBindingRowById,
deleteUserRowById,
getBindingByServerIface,
getBindingByServerIfacePeer,
getBindingRowById,
getUserRowById,
getUserRowByLogin,
@@ -35,6 +35,12 @@ import {
mapRosInterfaceType,
parseRawInterfaces,
} from "../iface-type.js"
import {
normalizeBindingPeer,
PeerBindError,
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
export class UsersServiceError extends Error {
constructor(
@@ -80,6 +86,8 @@ function toBindingDto(row: BindingRow): UserBinding {
serverCountry: meta.country,
interfaceName: row.interfaceName,
interfaceType: row.interfaceType,
peerPublicKey: row.peerPublicKey ?? "",
peerName: row.peerName ?? "",
comment: row.comment,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -179,11 +187,29 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
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)
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()
try {
const row = createBindingRow({
@@ -192,6 +218,8 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
serverId: input.serverId,
interfaceName: ifaceName,
interfaceType: type,
peerPublicKey,
peerName,
comment: (input.comment ?? "").trim(),
createdAt: now,
updatedAt: now,
@@ -199,7 +227,12 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
return toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
throw err
}
@@ -220,7 +253,7 @@ function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
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]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
@@ -238,13 +271,14 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
running: trafficSamples.running,
disabled: trafficSamples.disabled,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.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>()
ifaces = []
for (const r of rows) {
@@ -262,18 +296,47 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
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) => {
const bind = bindings.find((b) => b.interfaceName === iface.name)
const owner = bind ? usersById.get(bind.userId) : undefined
return {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
name: iface.name,
type: iface.type,
running: iface.running,
disabled: iface.disabled,
boundUserId: bind?.userId ?? null,
boundUserId: ifaceBind?.userId ?? 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))
}
+99
View File
@@ -0,0 +1,99 @@
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 { listTrafficFlowHostFiles } 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)))
}
function requestPublicHost(req: FastifyRequest): string {
const forwarded = req.headers["x-forwarded-host"]
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
return raw || req.hostname || ""
}
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, {
publicEndpoint: parsed.data.publicEndpoint,
requestHost: requestPublicHost(req),
})
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: listTrafficFlowHostFiles() })
})
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
+1 -1
View File
@@ -39,7 +39,7 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => {
}, async (req, reply) => {
const q = req.query as { serverId: number }
try {
return reply.send({ interfaces: listInterfaceCatalog(q.serverId) })
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
} catch (err) {
return sendServiceError(reply, err)
}
+21 -35
View File
@@ -21,6 +21,12 @@ import {
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
import {
putIpAddress,
putWireguardInterface,
putWireguardPeer,
toRosBody,
} from "../services/wireguard-ros.js"
function serverIdParam(v: string): string {
return decodeURIComponent(v)
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
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 }) {
return toRosBody({
interface: p.interfaceName,
@@ -99,20 +97,17 @@ async function applyParsedConfig(
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await client.put("/interface/wireguard", ifaceBody)
await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) {
await client.put("/ip/address", {
address: parsed.interface.address,
interface: name,
})
await putIpAddress(client, parsed.interface.address, name)
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await client.put(
"/interface/wireguard/peers",
await putWireguardPeer(
client,
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
@@ -164,30 +159,21 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.put(
"/interface/wireguard",
toRosBody({
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
}),
)
await putWireguardInterface(client, {
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
})
if (body.address) {
await client.put("/ip/address", {
address: body.address,
interface: body.name,
})
await putIpAddress(client, body.address, body.name)
}
if (body.peer) {
await client.put(
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
}
const list = await listWireGuardInterfaces({
@@ -255,7 +241,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put("/interface/wireguard/peers", peerToRosBody(body))
await putWireguardPeer(client, peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
+69 -9
View File
@@ -16,6 +16,38 @@ interface RosIfaceTraffic {
"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 {
running: boolean
lastRunAt: string | null
@@ -63,6 +95,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
rxBytes: trafficSamples.rxBytes,
txBytes: trafficSamples.txBytes,
sampledAt: trafficSamples.sampledAt,
@@ -73,7 +106,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
eq(trafficSamples.sampledAt, last.sampledAt),
))
.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> {
@@ -116,14 +149,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const txBytes = toNum(i["tx-byte"])
const running = (i.running ?? "false") === "true"
const disabled = (i.disabled ?? "false") === "true"
const prev = prevWave.get(interfaceName)
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
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
if (shouldIncludeIface(interfaceName, running, disabled)) {
sumRxMbps += bpsToMbps(rxBps)
sumTxMbps += bpsToMbps(txBps)
@@ -131,6 +157,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
return {
serverId: srv.id,
interfaceName,
peerPublicKey: "",
sampledAt: now,
rxBytes,
txBytes,
@@ -140,6 +167,39 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
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) {
db.insert(trafficSamples).values(rows).run()
}
@@ -0,0 +1,115 @@
import { generateNativeConf } from "./wireguard-config.js"
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
const COMPOSE_DIR = "/opt/cdn-mm"
export function buildHostWgQuickConf(): string {
const row = getTrafficFlowSettingsRow()
const peers = listHostPeers()
return generateNativeConf({
name: "wg-flow",
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,
endpoint: p.endpoint,
persistentKeepalive: p.endpoint ? 25 : undefined,
})),
})
}
export function buildHostComposeOverride(): string {
const row = getTrafficFlowSettingsRow()
return [
"# Docker Compose merge для /opt/cdn-mm",
"# Не править docker-compose.yml. Traefik не трогать.",
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
"# затем: docker compose up -d backend",
"# Docker userland-proxy может SNAT UDP source в 172.x — ingest сопоставит единственный JH.",
"",
"services:",
" backend:",
" environment:",
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
" ports:",
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
"",
].join("\n")
}
export function buildHostLinuxInstallSh(): string {
const row = getTrafficFlowSettingsRow()
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
const override = buildHostComposeOverride()
const collector = row.collectorIp
const flowPort = row.flowListenPort
return `#!/usr/bin/env bash
# WG-клиент на хосте /opt/cdn-mm JH:13232, IPFIX в контейнер backend.
# Запуск: sudo bash install-wg-flow.sh
set -euo pipefail
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
echo "Запустите от root: sudo bash $0" >&2
exit 1
fi
COLLECTOR_IP="${collector}"
FLOW_PORT="${flowPort}"
COMPOSE_DIR="${COMPOSE_DIR}"
if ! command -v wg >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
fi
install -d -m 700 /etc/wireguard
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
${conf}WGEOF
chmod 600 /etc/wireguard/wg-flow.conf
systemctl enable --now wg-quick@wg-flow
echo "=== wg show wg-flow ==="
wg show wg-flow
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
ip -4 addr show dev wg-flow
if [[ ! -d "\$COMPOSE_DIR" ]]; then
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
exit 1
fi
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
${override}OVEOF
cd "\$COMPOSE_DIR"
docker compose up -d backend
echo "=== UDP \${FLOW_PORT} на хосте ==="
ss -ulnp | grep -E "\${FLOW_PORT}" || true
echo "=== PortBindings mmapp-backend ==="
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
echo "=== handshake (keepalive 25s к JH:13232) ==="
wg show wg-flow
# ufw: исходящий WG не открывать; 4739 на WAN не публиковать
if command -v ufw >/dev/null 2>&1; then
ufw deny "\${FLOW_PORT}/udp" comment 'ipfix-not-public' || true
fi
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
`
}
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
return [
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
]
}
+274
View File
@@ -0,0 +1,274 @@
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 { pickServerIdForExporter } from "./traffic-flow-map-exporter.js"
import {
getTrafficFlowSettingsRow,
listHostPeers,
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 settings = getTrafficFlowSettingsRow()
const rows = db.select({
id: servers.id,
host: servers.host,
mgmtTunnelIp: servers.mgmtTunnelIp,
}).from(servers).all()
const byTunnelIp = new Map<string, number>()
const hostIps = new Map<string, number>()
for (const row of rows) {
if (row.mgmtTunnelIp) byTunnelIp.set(row.mgmtTunnelIp, row.id)
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.set(row.host, row.id)
}
return pickServerIdForExporter({
exporterIp,
overlayPrefix: settings.prefix,
byTunnelIp,
peers: listHostPeers(),
hostIps,
})
}
function queueFlows(exporterIp: string, flows: ParsedFlow[]): boolean {
const serverId = resolveServerId(exporterIp)
if (serverId == null) return false
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,
})
}
}
return true
}
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) return
if (!queueFlows(rinfo.address, flows)) {
recordFlowListenerError(
`IPFIX от ${rinfo.address}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
)
return
}
recordFlowListenerError("")
} 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 settings = getTrafficFlowSettingsRow()
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, settings.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,
lastExporterIp: settings.lastExporterIp ?? null,
lastError: settings.lastError || null,
packetsReceived: settings.packetsReceived,
}
}
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
queueFlows(exporterIp, flows)
flushPending()
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import {
bareIpv4,
ipInCidr,
isNatMasqueradeExporter,
normalizeExporterIp,
pickServerIdForExporter,
} from "./traffic-flow-map-exporter.js"
assert.equal(normalizeExporterIp("::ffff:172.18.0.2"), "172.18.0.2")
assert.equal(bareIpv4("10.255.254.3/32"), "10.255.254.3")
assert.equal(ipInCidr("10.255.254.3", "10.255.254.0/24"), true)
assert.equal(ipInCidr("172.18.0.2", "10.255.254.0/24"), false)
assert.equal(isNatMasqueradeExporter("172.18.0.2", "10.255.254.0/24"), true)
assert.equal(isNatMasqueradeExporter("10.255.254.3", "10.255.254.0/24"), false)
assert.equal(isNatMasqueradeExporter("10.0.0.12", "10.255.254.0/24"), true)
const byTunnel = new Map([["10.255.254.3", 7]])
assert.equal(pickServerIdForExporter({
exporterIp: "10.255.254.3",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: byTunnel,
peers: [],
hostIps: new Map(),
}), 7)
assert.equal(pickServerIdForExporter({
exporterIp: "172.18.0.2",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: byTunnel,
peers: [{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] }],
hostIps: new Map(),
}), 7)
assert.equal(pickServerIdForExporter({
exporterIp: "172.18.0.2",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: new Map([["10.255.254.3", 7], ["10.255.254.4", 8]]),
peers: [
{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] },
{ serverId: 8, address: "10.255.254.4", allowedIps: ["10.255.254.4/32"] },
],
hostIps: new Map(),
}), null)
assert.equal(pickServerIdForExporter({
exporterIp: "94.142.140.141",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: byTunnel,
peers: [],
hostIps: new Map([["94.142.140.141", 7]]),
}), 7)
console.log("traffic-flow-map-exporter.test.ts: ok")
@@ -0,0 +1,82 @@
export interface OverlayPeerRef {
serverId: number
address: string
allowedIps: string[]
}
export function normalizeExporterIp(ip: string): string {
const trimmed = ip.trim()
if (trimmed.toLowerCase().startsWith("::ffff:")) return trimmed.slice(7)
return trimmed
}
export function bareIpv4(value: string): string {
const raw = normalizeExporterIp(value).split("/")[0]?.trim() ?? ""
return raw
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split(".")
if (parts.length !== 4) return null
const n = parts.map((x) => Number(x))
if (n.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return null
return ((n[0]! << 24) | (n[1]! << 16) | (n[2]! << 8) | n[3]!) >>> 0
}
export function ipInCidr(ip: string, cidr: string): boolean {
const host = bareIpv4(ip)
const [base, bitsRaw] = cidr.split("/")
const bits = Number(bitsRaw ?? 32)
const a = ipv4ToInt(host)
const b = ipv4ToInt(bareIpv4(base ?? ""))
if (a == null || b == null || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
return (a & mask) === (b & mask)
}
/** Docker userland-proxy / bridge SNAT, не адрес из оверлея wg-flow. */
export function isNatMasqueradeExporter(ip: string, overlayPrefix: string): boolean {
const host = bareIpv4(ip)
if (!host) return false
if (ipInCidr(host, overlayPrefix)) return false
return ipInCidr(host, "10.0.0.0/8")
|| ipInCidr(host, "172.16.0.0/12")
|| ipInCidr(host, "192.168.0.0/16")
|| ipInCidr(host, "127.0.0.0/8")
}
export function pickServerIdForExporter(opts: {
exporterIp: string
overlayPrefix: string
byTunnelIp: Map<string, number>
peers: OverlayPeerRef[]
hostIps: Map<string, number>
}): number | null {
const exporter = bareIpv4(opts.exporterIp)
if (!exporter) return null
const exact = opts.byTunnelIp.get(exporter)
if (exact != null) return exact
for (const [ip, id] of opts.byTunnelIp) {
if (bareIpv4(ip) === exporter) return id
}
for (const peer of opts.peers) {
if (bareIpv4(peer.address) === exporter) return peer.serverId
if (peer.allowedIps.some((cidr) => ipInCidr(exporter, cidr) || bareIpv4(cidr) === exporter)) {
return peer.serverId
}
}
const byHost = opts.hostIps.get(exporter)
if (byHost != null) return byHost
if (!isNatMasqueradeExporter(exporter, opts.overlayPrefix)) return null
const tunnelIds = [...new Set(opts.byTunnelIp.values())]
if (tunnelIds.length === 1) return tunnelIds[0] ?? null
const peerIds = [...new Set(opts.peers.map((p) => p.serverId))]
if (peerIds.length === 1) return peerIds[0] ?? null
return null
}
@@ -0,0 +1,281 @@
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 { encodeRosId, 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"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.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, endpoint: string): string {
return [
`[Peer]`,
`PublicKey = ${publicKey}`,
`AllowedIPs = ${address}/32`,
`Endpoint = ${endpoint}:${JH_LISTEN_PORT}`,
`PersistentKeepalive = 25`,
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 ensureTrafficFlow(
client: MikrotikClient,
collectorIp: string,
port: number,
srcAddress: string,
): Promise<void> {
const body = toRosBody({
enabled: "yes",
interfaces: "all",
"active-flow-timeout": "1m",
"inactive-flow-timeout": "15s",
})
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow"))
const id = rows[0] ? rosRowId(rows[0]) : ""
if (id) {
await patchRosPath(client, `/ip/traffic-flow/${encodeRosId(id)}`, body)
} else {
await client.post("/ip/traffic-flow/set", body)
}
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
const targetBody = toRosBody({
"dst-address": collectorIp,
"src-address": srcAddress,
port: String(port),
version: "ipfix",
})
if (existing) {
const targetId = rosRowId(existing)
if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody)
return
}
await client.put("/ip/traffic-flow/target", targetBody)
}
export function usablePublicHost(raw: string | undefined): string {
if (!raw) return ""
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
const lower = host.toLowerCase()
if (!host) return ""
if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return ""
if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return ""
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return ""
return host
}
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
const keys = ensureHostKeys()
const settings = getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
if (!hostPublicKey) {
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
}
const server = getEnabledServerById(String(serverIdRaw))
if (!server || !server.enabled) {
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
}
const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim()
if (!endpointHost) {
throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 })
}
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
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 peerBody = {
interface: IFACE_NAME,
"public-key": hostPublicKey,
"allowed-address": `${settings.collectorIp}/32`,
comment: "MM traffic-flow collector",
name: "mm-collector",
}
if (!peer) {
await putWireguardPeer(client, peerBody)
steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)")
} else {
const id = rosRowId(peer)
const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim())
if (hadEndpoint && id) {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(id)}`)
await putWireguardPeer(client, peerBody)
steps.push("Пир пересоздан как сервер (endpoint снят)")
} else 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, address)
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src ${address})`)
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,
endpoint: peerEndpoint,
})
return {
ok: true,
serverId: server.id,
interfaceName: IFACE_NAME,
address,
publicKey,
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: listTrafficFlowHostFiles(),
}
} 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,69 @@
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")
import { usablePublicHost } from "./traffic-flow-overlay.js"
assert.equal(usablePublicHost("localhost:8000"), "")
assert.equal(usablePublicHost("127.0.0.1"), "")
assert.equal(usablePublicHost("192.168.1.10"), "")
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
resetFlowTemplatesForTests()
{
const tpl = Buffer.alloc(16 + 16 + 20)
tpl.writeUInt16BE(10, 0)
tpl.writeUInt16BE(tpl.length, 2)
tpl.writeUInt16BE(2, 16)
tpl.writeUInt16BE(16, 18)
tpl.writeUInt16BE(256, 20)
tpl.writeUInt16BE(2, 22)
tpl.writeUInt16BE(8, 24)
tpl.writeUInt16BE(4, 26)
tpl.writeUInt16BE(12, 28)
tpl.writeUInt16BE(4, 30)
const data = Buffer.alloc(16 + 12)
data.writeUInt16BE(10, 0)
data.writeUInt16BE(data.length, 2)
data.writeUInt16BE(256, 16)
data.writeUInt16BE(12, 18)
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
const fromTpl = parseFlowPacket(tpl, "172.18.0.2")
assert.equal(fromTpl.length, 0)
const fromData = parseFlowPacket(data, "172.18.0.2")
assert.equal(fromData.length, 1)
assert.equal(fromData[0]?.src, "10.1.1.8")
assert.equal(fromData[0]?.dst, "8.8.8.8")
}
console.log("traffic-flow-parse.test.ts: ok")
+299
View File
@@ -0,0 +1,299 @@
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 ipv6(buf: Buffer, offset: number): string {
const parts: string[] = []
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
return parts.join(":")
}
const VAR_LEN = 0xffff
function consumeField(
buf: Buffer,
off: number,
length: number,
limit: number,
): { data: Buffer; next: number } | null {
if (length === VAR_LEN) {
if (off >= limit) return null
const first = buf[off]!
if (first < 255) {
const end = off + 1 + first
if (end > limit) return null
return { data: buf.subarray(off + 1, end), next: end }
}
if (off + 3 > limit) return null
const len = buf.readUInt16BE(off + 1)
const end = off + 3 + len
if (end > limit) return null
return { data: buf.subarray(off + 3, end), next: end }
}
const end = off + length
if (end > limit) return null
return { data: buf.subarray(off, end), next: end }
}
function fixedRecordSize(fields: FieldSpec[]): number | null {
let n = 0
for (const f of fields) {
if (f.length === VAR_LEN) return null
n += f.length
}
return n
}
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,
limit: 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) {
const field = consumeField(buf, off, f.length, limit)
if (!field) return null
const { data } = field
switch (f.type) {
case 8:
if (data.length === 4) src = ipv4(data, 0)
break
case 12:
if (data.length === 4) dst = ipv4(data, 0)
break
case 27:
if (data.length === 16 && !src) src = ipv6(data, 0)
break
case 28:
if (data.length === 16 && !dst) dst = ipv6(data, 0)
break
case 225:
if (data.length === 4 && !src) src = ipv4(data, 0)
break
case 226:
if (data.length === 4 && !dst) dst = ipv4(data, 0)
break
case 4:
proto = readUint(data, 0, data.length)
break
case 7:
srcPort = readUint(data, 0, data.length)
break
case 11:
dstPort = readUint(data, 0, data.length)
break
case 1:
bytes = readUint(data, 0, data.length)
break
case 2:
packets = readUint(data, 0, data.length)
break
case 85:
if (!bytes) bytes = readUint(data, 0, data.length)
break
case 86:
if (!packets) packets = readUint(data, 0, data.length)
break
case 10:
inIface = String(readUint(data, 0, data.length))
break
default:
break
}
off = field.next
}
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
}
function parseDataRecords(
tpl: Template,
buf: Buffer,
recOff: number,
setEnd: number,
out: ParsedFlow[],
) {
const size = fixedRecordSize(tpl.fields)
while (recOff + 1 < setEnd) {
if (size != null && recOff + size > setEnd) break
const parsed = recordFromFields(tpl.fields, buf, recOff, setEnd)
if (!parsed) break
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
if (parsed.next <= recOff) break
recOff = parsed.next
}
}
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) parseDataRecords(tpl, buf, off + 4, setEnd, out)
}
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) parseDataRecords(tpl, buf, off + 4, setEnd, out)
}
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,126 @@
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,
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)
}
+25
View File
@@ -97,4 +97,29 @@ const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, [
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
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")
+19 -4
View File
@@ -4,6 +4,7 @@ export const SERIES_POINTS = 60
export interface TrafficSampleLike {
interfaceName: string
peerPublicKey?: string
sampledAt: string
rxBytes: number
txBytes: number
@@ -94,11 +95,16 @@ function parseIsoMs(iso: string): number {
return Number.isFinite(t) ? t : 0
}
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
export function buildTrafficFromSamples(
rows: TrafficSampleLike[],
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string | readonly string[],
peerPublicKey?: string,
): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
@@ -113,11 +119,12 @@ export function buildTrafficFromSamples(
}
if (rows.length === 0) return empty
const byIface = new Map<string, TrafficSampleLike[]>()
const bySeries = new Map<string, TrafficSampleLike[]>()
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)
byIface.set(r.interfaceName, arr)
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
}
const allowList = Array.isArray(onlyInterface)
@@ -132,12 +139,20 @@ export function buildTrafficFromSamples(
let txBytesDelta = 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.includes(name)) continue
} else if (isLoopbackName(name)) {
continue
}
if (peerPublicKey === undefined) {
if (peer !== "") continue
} else if (peer !== peerPublicKey) {
continue
}
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
+11 -19
View File
@@ -17,6 +17,8 @@ export interface BoundIfaceTrafficDto {
userName: string
interfaceName: string
interfaceType: string
peerPublicKey: string
peerName: string
comment: string
serverId: string
serverName: string
@@ -77,22 +79,6 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
return users.map((user) => {
const parts: BuiltTrafficSeries[] = []
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) {
let rows = sampleCache.get(b.serverId)
@@ -100,19 +86,25 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
rows = readServerSamplesInRange(b.serverId, sinceIso)
sampleCache.set(b.serverId, rows)
}
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName)
const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)]
const peerKey = b.peerPublicKey ?? ""
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))
.at(-1)
const running = Boolean(last?.running) && !last?.disabled
interfaces.push({
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
bindingId: b.id,
userId: user.id,
userLogin: user.login,
userName: user.name,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: peerKey,
peerName: b.peerName ?? "",
comment: b.comment,
serverId: String(b.serverId),
serverName: b.serverName,
+12
View File
@@ -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"),
}
}
+3 -3
View File
@@ -35,7 +35,7 @@ export type WgParsedConfig = {
export type WgExportIface = {
name: string
listenPort: number
listenPort?: number
mtu: number
comment?: string
enabled?: boolean
@@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
}
if (iface.address) lines.push(`Address = ${iface.address}`)
lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
@@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
+47
View File
@@ -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
}
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 }
+50
View File
@@ -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,113 @@
"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,
emptyHint,
}: {
rows: FlowTalkerDto[]
emptyHint?: string
}) {
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={
emptyHint
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
}
/>
)
}
export { TrafficFlowsDataGrid }
@@ -12,7 +12,7 @@ import {
type AppUser,
type InterfaceType,
} 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"> = {
ether: "outline",
@@ -87,23 +87,29 @@ function UsersExpandedDetail({
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{items.map((b) => {
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 (
<div
key={b.id}
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 />
</IconTile>
<div className="min-w-0">
<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>
<div className="flex flex-wrap items-center gap-1 mt-0.5">
<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}
{b.comment ? (
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{b.comment}
+3 -3
View File
@@ -60,10 +60,10 @@ function highlightLine(line: string): string {
return "text-foreground/90"
}
function CodeBlock({ code }: { code: string }) {
function CodeBlock({ code, className }: { code: string; className?: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
{lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}>
{line || " "}
@@ -281,4 +281,4 @@ function CodeExportSheet({
)
}
export { CodeExportSheet, downloadText }
export { CodeExportSheet, CodeBlock, downloadText }
+229
View File
@@ -0,0 +1,229 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
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"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
InfoIcon,
} from "lucide-react"
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 [endpoint, setEndpoint] = useState("")
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
const [copied, setCopied] = useState(false)
const [tab, setTab] = useState("linux")
useEffect(() => {
if (!open) return
setResult(null)
setCopied(false)
setTab("linux")
const first = jumpHosts[0]
const nextId = first ? String(first.id) : ""
setServerId(nextId)
setEndpoint(first?.host ?? "")
}, [open, jumpHosts])
function handleServerChange(id: string) {
setServerId(id)
const selected = jumpHosts.find((s) => String(s.id) === id)
if (selected) setEndpoint(selected.host)
}
const formats = result?.hostFiles ?? []
const active = formats.find((f) => f.id === tab) ?? formats[0]
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
const selectedLabel = selectedHost
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
: "Выберите сервер…"
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
async function handleSubmit() {
if (!serverId || !endpoint.trim()) return
setBusy(true)
try {
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
} finally {
setBusy(false)
}
}
function handleCopy() {
const code = active?.code ?? ""
if (!code) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
toast.success("Скопировано")
setTimeout(() => setCopied(false), 1800)
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
Подключить jump-host
</SheetTitle>
<SheetDescription>
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
<FormField label="Jump-host" required>
<Select
value={serverId || undefined}
onValueChange={(v) => handleServerChange(String(v ?? ""))}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start" className="min-w-(--anchor-width)">
{jumpHosts.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name || s.host} ({s.host})
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="Публичный IP или DNS jump-host"
required
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
>
<Input
className="font-mono"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="jh.example.com"
autoComplete="off"
/>
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
<InfoIcon />
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
<AlertDescription>
Скопируйте вкладку Linux и выполните от root. WG клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
</AlertDescription>
</Alert>
<ul className="flex flex-col gap-1.5">
{result.steps.map((s) => (
<li key={s} className="flex items-start gap-2 text-sm">
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
<span>{s}</span>
</li>
))}
</ul>
{formats.length > 0 && active ? (
<div className="flex min-h-0 flex-col gap-3">
<Tabs
value={tab}
onValueChange={(v) => {
setTab(String(v))
setCopied(false)
}}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
</ScrollArea>
</FramePanel>
</Frame>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => downloadText(active.filename, active.code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
<Button type="button" size="sm" onClick={handleCopy}>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</div>
) : null}
</div>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
Закрыть
</SheetClose>
<Button disabled={!canSubmit} 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 }
+229 -59
View File
@@ -19,12 +19,23 @@ import {
StepperTrigger,
} from "@/components/reui/stepper"
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 { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { listInterfaceCatalog } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
bindingDiffKey,
bindingTitle,
catalogForServer,
defaultSections,
defaultServers,
@@ -43,10 +54,28 @@ import {
type UserServerOption,
} from "@/lib/users"
import {
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
TrashIcon, WrenchIcon,
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
} 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> = {
"Обзор": <LayoutDashboardIcon className="size-3" />,
"Данные": <EyeIcon className="size-3" />,
@@ -124,7 +153,8 @@ function UserSheet({
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
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("")
useEffect(() => {
@@ -133,7 +163,8 @@ function UserSheet({
setSheetStep(1)
setErrors({})
setCatalogServerId(servers[0]?.id ?? "")
setSelectedNames([])
setSelectedPicks([])
setExpandedWg(null)
setNewComment("")
}, [open, user, servers])
@@ -212,29 +243,32 @@ function UserSheet({
const catalogSrv = servers.find((s) => s.id === catalogServerId)
const addSelectedBindings = () => {
if (!catalogSrv || selectedNames.length === 0) return
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
if (!catalogSrv || selectedPicks.length === 0) return
const existing = new Set(form.bindings.map(bindingDiffKey))
const next: InterfaceBinding[] = [...form.bindings]
for (const name of selectedNames) {
const key = `${catalogSrv.id}::${name}`
for (const pick of selectedPicks) {
const key = bindingDiffKey({
serverId: catalogSrv.id,
interfaceName: pick.interfaceName,
peerPublicKey: pick.peerPublicKey,
})
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({
id: `pending-${catalogSrv.id}-${name}`,
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
userId: user?.id ?? "",
serverId: catalogSrv.id,
serverName: catalogSrv.name,
serverSite: catalogSrv.site,
serverCountry: catalogSrv.country,
interfaceName: name,
interfaceType: iface.type,
interfaceName: pick.interfaceName,
interfaceType: pick.type,
peerPublicKey: pick.peerPublicKey || undefined,
peerName: pick.peerName,
comment: newComment.trim(),
})
}
setForm((f) => ({ ...f, bindings: next }))
setSelectedNames([])
setSelectedPicks([])
setNewComment("")
}
@@ -242,15 +276,26 @@ function UserSheet({
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
}
const toggleName = (name: string) => {
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
const togglePick = (pick: CatalogPick) => {
setSelectedPicks((prev) => {
const key = pickKey(pick)
return prev.some((p) => pickKey(p) === key)
? prev.filter((p) => pickKey(p) !== key)
: [...prev, pick]
})
}
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],
)
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
return (
<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">
@@ -411,7 +456,7 @@ function UserSheet({
<StepperContent value={4} className="flex flex-col">
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
Привязка интерфейсов сервера. Один интерфейс один пользователь.
Ethernet и GRE целиком. WireGuard только пир (public-key).
</p>
<div className="py-3 flex flex-col gap-3 border-b">
@@ -419,56 +464,178 @@ function UserSheet({
<select
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
value={catalogServerId}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</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">
{catalog.length === 0 && (
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
)}
{catalog.map((iface) => {
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(iface.name)
const disabled = taken || mine
return (
<label
key={iface.name}
className={cn(
"flex items-center gap-2 py-0.5 text-xs",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
)}
>
<input
type="checkbox"
disabled={disabled}
checked={selectedNames.includes(iface.name)}
onChange={() => toggleName(iface.name)}
className="rounded border-input accent-primary"
/>
<span className="font-mono">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
</Badge>
{taken && (
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
)}
{mine && !taken && (
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
)}
</label>
)
})}
</div>
<Frame dense spacing="sm">
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
{catalog.length === 0 && (
<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) => {
const tile = IFACE_TILE[iface.type]
const Icon = tile.icon
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 (
<div key={iface.name} className="flex flex-col gap-0.5">
<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(
"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="size-10.5 shrink-0 text-success">
<KeyRoundIcon />
</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">{peer.name || peer.publicKey}</span>
{peer.latestHandshake ? (
<Badge variant="success-light" size="xs">handshake</Badge>
) : null}
</ItemTitle>
</ItemContent>
<ItemActions>
{taken ? (
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{peer.boundUserLogin}</span>
) : mine ? (
<span className="text-[10px] text-muted-foreground">уже привязан</span>
) : selected ? (
<Badge variant="secondary" size="xs">Выбран</Badge>
) : null}
</ItemActions>
</Item>
)
})}
</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
className="h-8 text-xs"
placeholder="Комментарий (необязательно)"
value={newComment}
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" />Привязать
</Button>
</div>
@@ -480,8 +647,11 @@ function UserSheet({
{form.bindings.map((b) => (
<div key={b.id} className="flex items-center gap-2 py-1">
<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>
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
) : null}
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
<Button
size="sm"
+4
View File
@@ -134,6 +134,10 @@ services:
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
# IPFIX: внутри контейнера слушать все iface; на хосте bind только WG-IP после wg-quick@wg-flow
FLOW_LISTEN_HOST: "0.0.0.0"
# ports:
# - "10.255.254.1:4739:4739/udp"
volumes:
- ./data/mm:/app/data
networks:
+4
View File
@@ -5,8 +5,12 @@ services:
restart: unless-stopped
ports:
- "8000:8000"
# IPFIX: только на WG-адресе хоста после `wg-quick up wg-flow`, не 0.0.0.0
# - "10.255.254.1:4739:4739/udp"
environment:
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
# Внутри контейнера слушаем все интерфейсы; на хосте UDP 4739 публикуется только на WG-IP
FLOW_LISTEN_HOST: "0.0.0.0"
volumes:
- /opt/mmapp/data:/app/data
labels:
+78 -13
View File
@@ -15,9 +15,32 @@ export interface InterfaceBinding {
serverCountry: string
interfaceName: string
interfaceType: InterfaceType
peerPublicKey?: string
peerName?: 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 {
name: string
login: string
@@ -43,15 +66,6 @@ export interface AppUser {
bindings: InterfaceBinding[]
}
export interface CatalogIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
boundUserId: string | null
boundUserLogin: string | null
}
export interface UserServerOption {
id: string
name: string
@@ -138,17 +152,39 @@ export const MOCK_IFACE_CATALOG: Record<string, CatalogIface[]> = {
{ 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-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: [
{ 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-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: "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(
id: string,
userId: string,
@@ -156,6 +192,7 @@ function bind(
interfaceName: string,
interfaceType: InterfaceType,
comment: string,
peer?: { publicKey: string; name: string },
): InterfaceBinding {
const srv = servers.find((s) => s.id === serverId)
return {
@@ -167,6 +204,8 @@ function bind(
serverCountry: srv?.country ?? "UN",
interfaceName,
interfaceType,
peerPublicKey: peer?.publicKey,
peerName: peer?.name,
comment,
}
}
@@ -180,7 +219,7 @@ export const INIT_USERS: AppUser[] = [
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
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("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
],
@@ -213,8 +252,34 @@ export const INIT_USERS: AppUser[] = [
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
const base = MOCK_IFACE_CATALOG[serverId] ?? []
return base.map((iface) => {
if (iface.type === "wg") {
const peers = (iface.peers ?? []).map((peer) => {
const owner = users.find((u) =>
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),
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name && !(b.peerPublicKey ?? "")),
)
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
BIN
View File
Binary file not shown.
+4
View File
@@ -41,6 +41,10 @@
"./users": {
"types": "./dist/users.d.ts",
"default": "./dist/users.js"
},
"./traffic-flow": {
"types": "./dist/traffic-flow.d.ts",
"default": "./dist/traffic-flow.js"
}
},
"dependencies": {
+1
View File
@@ -5,3 +5,4 @@ export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
export * from "./users.js"
export * from "./traffic-flow.js"
+102
View File
@@ -0,0 +1,102 @@
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),
endpoint: z.string().optional(),
})
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()]),
publicEndpoint: z.string().optional(),
})
export const trafficFlowHostFileSchema = z.object({
id: z.string(),
label: z.string(),
filename: z.string(),
code: z.string(),
})
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()),
hostFiles: z.array(trafficFlowHostFileSchema),
})
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),
lastExporterIp: z.string().nullable().optional(),
lastError: z.string().nullable().optional(),
packetsReceived: z.number().int().nonnegative().optional(),
})
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 TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
+17
View File
@@ -23,6 +23,8 @@ export const userBindingSchema = z.object({
serverCountry: z.string(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema,
peerPublicKey: z.string().default(""),
peerName: z.string().default(""),
comment: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -71,6 +73,8 @@ export const userBindingCreateSchema = z.object({
serverId: z.coerce.number().int().positive(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema.optional(),
peerPublicKey: z.string().optional(),
peerName: z.string().optional(),
comment: z.string().optional(),
})
@@ -78,6 +82,16 @@ export const interfaceCatalogQuerySchema = z.object({
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({
name: z.string(),
type: interfaceTypeSchema,
@@ -85,6 +99,8 @@ export const catalogInterfaceSchema = z.object({
disabled: z.boolean(),
boundUserId: z.string().nullable(),
boundUserLogin: z.string().nullable(),
peers: z.array(catalogPeerSchema).optional(),
peersError: z.string().optional(),
})
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 AppUserUpdate = z.infer<typeof appUserUpdateSchema>
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
export type CatalogPeer = z.infer<typeof catalogPeerSchema>
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
+52
View File
@@ -0,0 +1,52 @@
import type {
FlowStatsDto,
TrafficFlowHostFile,
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
} from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
export type { TrafficFlowHostFile }
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 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,
publicEndpoint?: string,
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({ serverId, publicEndpoint }),
})
}
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
}
+2
View File
@@ -28,6 +28,8 @@ export function toFrontendBinding(b: UserBinding): InterfaceBinding {
serverCountry: b.serverCountry,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey || undefined,
peerName: b.peerName || undefined,
comment: b.comment,
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long