Compare commits

...
11 Commits
Author SHA1 Message Date
DenozordecandCursor fc29dcede7 feat(traffic-flow): add NAT fields to flow processing and analytics
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m22s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m51s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
- Introduced new fields for NAT source and destination IPs, as well as their respective ports, in the flow data model.
- Updated database schema and migration scripts to accommodate the new NAT fields in the `flow_buckets` table.
- Enhanced flow analytics and processing functions to utilize the new NAT fields, improving accuracy in traffic flow analysis.
- Added tests to validate the handling of NAT data in various scenarios, ensuring robustness in flow processing.

Co-authored-by: Cursor <[email protected]>
2026-09-11 23:01:48 +07:00
DenozordecandCursor 5f774ce26e feat(gre): enhance GRE tunnel status handling and IPsec configuration
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 3m1s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m31s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Introduced a new `greStatusMeta` function to streamline status retrieval and default handling.
- Updated GRE tunnel command generation to improve IPsec configuration handling, including optional fields for encryption and authentication algorithms.
- Enhanced the GRE tunnels data grid to display IPsec settings more clearly, including handling cases where certain parameters may be undefined.
- Refactored status display logic in the GRE page to utilize the new status meta function for consistency.

Co-authored-by: Cursor <[email protected]>
2026-09-11 15:39:32 +07:00
DenozordecandCursor 25b82997b6 feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations.
- Introduced new components for displaying configuration history and integrated them into the respective pages.
- Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application.
- Updated state management to handle loading and restoring states effectively, improving user experience during data operations.
- Enhanced tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 14:03:00 +07:00
DenozordecandCursor b4a3c3a925 feat(filters, recursive-routes): enhance configuration history and live data handling
- Introduced configuration history management in Filters and Recursive Routes pages, allowing users to view and restore previous configurations.
- Updated state management to handle live data loading and error states more effectively, improving user experience during data fetching.
- Added new components for displaying configuration history and integrated them into existing pages.
- Enhanced API interactions to support fetching and applying configuration revisions, ensuring data consistency across the application.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 12:15:36 +07:00
DenozordecandCursor 9c0ee7940e feat(bgp, vxlan, ospf): enhance server data handling and introduce new routes
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience.
- Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval.
- Implemented mapping functions for backend server data to frontend types, ensuring consistency across components.
- Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <[email protected]>
2026-09-11 11:18:04 +07:00
DenozordecandCursor 5750590b68 feat(traffic-flow): add rebuild facts endpoint and enhance traffic flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m50s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
- Introduced a new endpoint `/traffic/flow/rebuild-facts` to rebuild flow facts from buckets, improving data accuracy and management.
- Updated traffic flow analytics to utilize the new `resolveInternetDest` function for better destination resolution.
- Enhanced tests for traffic flow IP handling and added new utility functions for managing internet destinations.

Co-authored-by: Cursor <[email protected]>
2026-09-11 10:32:51 +07:00
DenozordecandCursor 3c42c114f5 refactor(statistics): improve component structure and enhance state management
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
Docker images / frontend-image (push) Successful in 4m11s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- Introduced Suspense for lazy loading in StatisticsPage to optimize rendering.
- Refactored StatisticsPage to separate inner logic into StatisticsPageInner for better readability.
- Updated PeriodSelector to utilize useCallback for handling state changes, improving performance and clarity.

Co-authored-by: Cursor <[email protected]>
2026-09-11 09:38:31 +07:00
DenozordecandCursor 5aef419582 fix(statistics): считать уникальный payload без дублей hops
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 3m13s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <[email protected]>
2026-09-11 01:27:40 +07:00
DenozordecandCursor 0c0dfa1df7 fix(statistics): не двоить overlay и транзит в кубе
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 3m25s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m9s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-11 00:46:25 +07:00
DenozordecandCursor c162a41bc0 fix(network-map): подписать unbound-пути парой серверов
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 2m22s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 2m41s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
Co-authored-by: Cursor <[email protected]>
2026-09-10 22:19:05 +07:00
DenozordecandCursor 97e43b2335 feat(statistics): enhance interface handling and data aggregation
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m29s
Docker images / frontend-image (push) Successful in 3m24s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m39s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Updated the statistics aggregation service to improve interface resolution and data handling. Introduced new functions for managing interface aliases and collapsing server interface rows, ensuring accurate data representation. Enhanced test coverage for interface resolution and added checks for new functionality.

- Implemented `factIfaceAliases` and `collapseServerIfaceRows` for better interface data management.
- Updated `resolveIfaceName` to handle additional cases for interface indexing.
- Enhanced tests for interface resolution and aggregation logic.

Co-authored-by: Cursor <[email protected]>
2026-09-10 21:49:50 +07:00
93 changed files with 7213 additions and 1107 deletions
+130 -42
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { Fragment, useState, useMemo, useEffect } from "react" import { useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
@@ -25,6 +25,9 @@ import {
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { servers as mockServers, type Server } from "@/lib/data"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -233,6 +236,37 @@ interface BackendBgpSession {
capabilities: string[]; lastError: string | null capabilities: string[]; lastError: string | null
} }
interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function backendToFrontend(b: BackendBgpSession): BgpSession { function backendToFrontend(b: BackendBgpSession): BgpSession {
return { return {
id: `${b.serverId}-${b.id}`, id: `${b.serverId}-${b.id}`,
@@ -623,27 +657,40 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
export default function BgpPage() { export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions") const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([]) const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null) const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null) const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0) const [fetchTick, setFetchTick] = useState(0)
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive) {
queueMicrotask(() => {
setLiveSessions([])
setLiveServers([])
setLiveError(null)
})
return
}
let cancelled = false let cancelled = false
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) setLiveError(null)
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions") void Promise.all([
.then(data => { requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
.then(([data, servers]) => {
if (cancelled) return if (cancelled) return
setLiveSessions(data.map(backendToFrontend)) setLiveSessions(data.map(backendToFrontend))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFetchedAt(new Date()) setFetchedAt(new Date())
setLoading(false) setLoading(false)
}) })
@@ -656,50 +703,87 @@ export default function BgpPage() {
return () => { cancelled = true } return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick]) }, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI const allSessions = isLive ? liveSessions : SESSIONS
const sessions = isLive ? liveSessions : SESSIONS const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const sessions = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
return allSessions.filter((s) => s.serverId === effectiveServerId)
}, [allSessions, effectiveServerId])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const s of allSessions) {
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, allSessions])
const established = sessions.filter(s => s.state === "Established").length const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established const notEstab = sessions.length - established
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0) const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const serverCount = useMemo( const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size, () => new Set(sessions.map(s => s.serverId)).size,
[liveSessions], [sessions],
) )
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader items={railItems}
crumbs={[{ label: "Управление" }, { label: "BGP" }]} selectedId={effectiveServerId}
actions={ onSelect={setSelectedServerId}
<> showAll
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}> allCount={displayServers.length}
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> loading={isLive && loading && displayServers.length === 0}
Обновить header={
</Button> <PageHeader
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button> crumbs={[{ label: "Управление" }, { label: "BGP" }]}
</> actions={
} <>
/> <ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
{/* tab bar */} <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<div className="border-b bg-background shrink-0"> Обновить
<div className="flex items-center px-6"> </Button>
{TABS.map(t => ( <Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<button key={t.id} onClick={() => setActiveTab(t.id)} </>
className={cn( }
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px", />
activeTab === t.id }
? "border-primary text-foreground" banner={
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border", <div className="border-b bg-background shrink-0">
)}> <div className="flex items-center px-6">
{t.icon}{t.label} {TABS.map(t => (
</button> <button key={t.id} onClick={() => setActiveTab(t.id)}
))} className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div> </div>
</div> }
>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* data source banner */} {/* data source banner */}
@@ -729,11 +813,16 @@ export default function BgpPage() {
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription> <AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert> </Alert>
)} )}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && ( {isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере BGP не настроен ни на одном сервере
</div> </div>
)} )}
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На выбранном сервере нет BGP-сессий
</div>
)}
{mode === "mock" && ( {mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"> <span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные Моковые данные
@@ -794,7 +883,6 @@ export default function BgpPage() {
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />} {activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
</div> </div>
</div> </ServerRailLayout>
</div>
) )
} }
+253 -70
View File
@@ -1,15 +1,17 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuSeparator,
@@ -18,18 +20,47 @@ import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon, RefreshCwIcon,
} from "lucide-react" } from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
function serverFor(id: string) { interface ContainersApiResponse {
return servers.find((s) => s.id === id) containers: RouterContainer[]
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
} }
function statusConfig(status: RouterContainer["status"]) { function statusConfig(status: RouterContainer["status"]) {
@@ -52,10 +83,8 @@ function statusConfig(status: RouterContainer["status"]) {
}[status] }[status]
} }
// ─── RSC generator ──────────────────────────────────────────────────────────── function generateContainerRsc(c: RouterContainer, serverById: Record<string, Server>): string {
const srv = serverById[c.serverId]
function generateContainerRsc(c: RouterContainer): string {
const srv = serverFor(c.serverId)
const lines: string[] = [] const lines: string[] = []
lines.push(`# RouterOS Container — ${c.name}`) lines.push(`# RouterOS Container — ${c.name}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
@@ -63,13 +92,11 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(`# RouterOS 7.4+ · /container`) lines.push(`# RouterOS 7.4+ · /container`)
lines.push(``) lines.push(``)
// interface
for (const iface of c.interfaces) { for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`) lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
} }
lines.push(``) lines.push(``)
// envs
if (c.envs.length > 0) { if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`) lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) { for (const { key, value } of c.envs) {
@@ -78,7 +105,6 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(``) lines.push(``)
} }
// mounts
for (const m of c.mounts) { for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`) lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`) if (m.src) lines.push(` src=${m.src} \\`)
@@ -86,7 +112,6 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(``) lines.push(``)
} }
// container
lines.push(`/container/add \\`) lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`) lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`) lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
@@ -100,12 +125,18 @@ function generateContainerRsc(c: RouterContainer): string {
return lines.join("\n") return lines.join("\n")
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── function ExportSheet({
open, container, onClose, serverById,
function ExportSheet({ open, container, onClose }: { }: {
open: boolean; container: RouterContainer | null; onClose: () => void open: boolean
container: RouterContainer | null
onClose: () => void
serverById: Record<string, Server>
}) { }) {
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container]) const code = useMemo(
() => (container ? generateContainerRsc(container, serverById) : ""),
[container, serverById],
)
return ( return (
<CodeExportSheet <CodeExportSheet
@@ -125,17 +156,29 @@ function ExportSheet({ open, container, onClose }: {
) )
} }
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({ function ContainerCard({
container, container,
server,
live,
busy,
onExport, onExport,
onStart,
onStop,
onRestart,
onRemove,
}: { }: {
container: RouterContainer container: RouterContainer
server?: Server
live: boolean
busy: boolean
onExport: () => void onExport: () => void
onStart: () => void
onStop: () => void
onRestart: () => void
onRemove: () => void
}) { }) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status) const cfg = statusConfig(container.status)
const canMutate = live && Boolean(container.rosId)
return ( return (
<Frame dense className="w-full overflow-hidden"> <Frame dense className="w-full overflow-hidden">
@@ -150,29 +193,36 @@ function ContainerCard({
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger render={ <DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0"> <Button variant="ghost" size="icon" className="size-7 shrink-0" disabled={busy}>
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
</Button> </Button>
} /> } />
<DropdownMenuContent side="bottom" align="end"> <DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? ( {container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onStop}>
<StopCircleIcon className="size-4 text-amber-500" />Остановить
</DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onStart}>
<PlayIcon className="size-4 text-emerald-500" />Запустить
</DropdownMenuItem>
)} )}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem> <DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem> <DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem> <DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem> <DropdownMenuItem disabled={!canMutate} onClick={onRestart}>
<PowerIcon className="size-4" />Перезапустить
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem> <DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="px-4 py-3 flex flex-col gap-3"> <div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" /> <BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80"> <span className="font-mono text-xs text-foreground/80">
@@ -180,17 +230,15 @@ function ContainerCard({
</span> </span>
</div> </div>
{/* server */} {server && (
{srv && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" /> <ServerIcon className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} /> <Flag code={server.country} size={12} />
<span className="font-mono">{srv.name}</span> <span className="font-mono">{server.name}</span>
</div> </div>
)} )}
{/* uptime + stats */} {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
{container.status === "running" && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5"> <div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && ( {container.uptime && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -216,7 +264,6 @@ function ContainerCard({
</div> </div>
)} )}
{/* interfaces */}
{container.interfaces.length > 0 && ( {container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => ( {container.interfaces.map((i) => (
@@ -227,7 +274,6 @@ function ContainerCard({
</div> </div>
)} )}
{/* mounts */}
{container.mounts.length > 0 && ( {container.mounts.length > 0 && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => ( {container.mounts.map((m, idx) => (
@@ -249,50 +295,183 @@ function ContainerCard({
) )
} }
// ════════════════════════════════════════════════════════════════════════════
export default function ContainersPage() { export default function ContainersPage() {
const [search, setSearch] = useState("") const { mode, backendUrl } = useDataSource()
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all") const isLive = mode === "live"
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null) const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
setLiveError(null)
try {
const [cRes, sRes] = await Promise.all([
requestJson<ContainersApiResponse>(backendUrl, "/api/containers"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveContainers(cRes.containers ?? [])
setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveContainers([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveContainers([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayContainers = isLive ? liveContainers : mockContainers
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scoped = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
return displayContainers.filter((c) => c.serverId === effectiveServerId)
}, [displayContainers, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayContainers.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayContainers])
const filtered = useMemo(() => { const filtered = useMemo(() => {
return routerContainers.filter((c) => { return scoped.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
return ( return (
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) || c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
) )
}) })
}, [search, statusFilter]) }, [search, statusFilter, scoped, serverById])
const running = routerContainers.filter((c) => c.status === "running").length const running = scoped.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length const stopped = scoped.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length const errors = scoped.filter((c) => c.status === "error").length
async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") {
if (!isLive || !c.rosId) {
toast.info("Действие доступно только в live-режиме")
return
}
if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return
setBusyId(c.id)
try {
await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, {
method: "POST",
body: JSON.stringify({ rosId: c.rosId }),
})
const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" }
toast.success(`${c.name}: ${labels[action]}`)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка RouterOS")
} finally {
setBusyId(null)
}
}
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> onSelect={setSelectedServerId}
<BoxIcon className="size-4" />Новый контейнер showAll
</Button> allCount={displayServers.length}
} loading={isLive && loading && displayServers.length === 0}
/> header={
<PageHeader
<div className="flex-1 overflow-y-auto p-6"> crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm">
<BoxIcon className="size-4" />Новый контейнер
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{isLive && liveError && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && displayContainers.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка контейнеров" aria-label="Сводка контейнеров"
items={[ items={[
{ {
id: "all", id: "all",
label: "Всего", label: "Всего",
value: routerContainers.length, value: scoped.length,
icon: <BoxIcon className="size-4" />, icon: <BoxIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -321,7 +500,6 @@ export default function ContainersPage() {
]} ]}
/> />
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" /> <BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div> <div>
@@ -333,7 +511,6 @@ export default function ContainersPage() {
</div> </div>
</div> </div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]"> <div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" /> <SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
@@ -363,7 +540,6 @@ export default function ContainersPage() {
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span> <span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
</div> </div>
{/* Grid */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" /> <BoxIcon className="size-10 mb-3 opacity-20" />
@@ -376,13 +552,19 @@ export default function ContainersPage() {
<ContainerCard <ContainerCard
key={c.id} key={c.id}
container={c} container={c}
server={serverById[c.serverId]}
live={isLive}
busy={busyId === c.id}
onExport={() => setExportContainer(c)} onExport={() => setExportContainer(c)}
onStart={() => { void mutate(c, "start") }}
onStop={() => { void mutate(c, "stop") }}
onRestart={() => { void mutate(c, "restart") }}
onRemove={() => { void mutate(c, "remove") }}
/> />
))} ))}
</div> </div>
)} )}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
@@ -442,13 +624,14 @@ export default function ContainersPage() {
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet <ExportSheet
open={!!exportContainer} open={!!exportContainer}
container={exportContainer} container={exportContainer}
onClose={() => setExportContainer(null)} onClose={() => setExportContainer(null)}
serverById={serverById}
/> />
</div> </>
) )
} }
+172 -138
View File
@@ -27,7 +27,7 @@ import {
StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon, StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
FileCodeIcon, CopyIcon, NetworkIcon, TagIcon, FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon, ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
CheckCircle2Icon, XCircleIcon, CircleDashedIcon, RefreshCwIcon, RefreshCwIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
@@ -36,13 +36,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner" import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
function innerIpToGateway(ip: string) { return ip.split("/")[0] } function innerIpToGateway(ip: string) { return ip.split("/")[0] }
@@ -1239,6 +1239,9 @@ interface BackendServer {
interface LiveFiltersResponse { interface LiveFiltersResponse {
rulesets: ServerFilterRuleset[] rulesets: ServerFilterRuleset[]
greTunnels: GreTunnel[] greTunnels: GreTunnel[]
live?: boolean
stale?: boolean
error?: string
} }
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] { function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
@@ -1325,18 +1328,14 @@ export default function FiltersPage() {
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm()) const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const [copyOpen, setCopyOpen] = useState(false) const [copyOpen, setCopyOpen] = useState(false)
const [syncBusy, setSyncBusy] = useState<"from" | "to" | null>(null) const [applyBusy, setApplyBusy] = useState(false)
const [routerCompare, setRouterCompare] = useState<{ const [liveStale, setLiveStale] = useState(false)
serverId: string const [historyOpen, setHistoryOpen] = useState(false)
byCommunity: Record<string, FilterRouterSyncStatus> const [historyLoading, setHistoryLoading] = useState(false)
} | null>(null) const [historyRestoring, setHistoryRestoring] = useState(false)
const [routerCompareLoading, setRouterCompareLoading] = useState(false) const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
useEffect(() => {
setRouterCompare(null)
}, [selectedServerId])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
@@ -1347,6 +1346,7 @@ export default function FiltersPage() {
setRulesets(buildRulesets(servers, serverFilterRulesets)) setRulesets(buildRulesets(servers, serverFilterRulesets))
setSelectedServerId(servers[0]?.id ?? "") setSelectedServerId(servers[0]?.id ?? "")
setLiveLoadState("idle") setLiveLoadState("idle")
setLiveStale(false)
}) })
return return
} }
@@ -1390,18 +1390,43 @@ export default function FiltersPage() {
}) })
}, [isLive, apiFetch]) }, [isLive, apiFetch])
const loadLiveRules = useCallback(async (serverId: string) => {
if (!isLive || !serverId) return
try {
const fresh = await apiFetch<LiveFiltersResponse>(
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
)
const liveRules = fresh.rulesets.find((r) => r.serverId === serverId)?.rules ?? fresh.rulesets[0]?.rules ?? []
setRulesets((prev) => {
const has = prev.some((rs) => rs.serverId === serverId)
if (!has) return [...prev, { serverId, rules: liveRules }]
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
})
setLiveStale(Boolean(fresh.stale))
if (fresh.greTunnels?.length) {
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
}
} catch (err) {
setLiveStale(true)
toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
}
}, [isLive, apiFetch])
useEffect(() => { useEffect(() => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId || liveLoadState !== "idle") return
if (!liveServers.some((s) => s.id === selectedServerId)) return
void Promise.all([ void Promise.all([
loadLiveRules(selectedServerId),
ensureGreTunnels(selectedServerId), ensureGreTunnels(selectedServerId),
ensureRecursiveRoutes(selectedServerId), ensureRecursiveRoutes(selectedServerId),
]) ])
}, [isLive, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes]) }, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
const allServers = isLive ? liveServers : servers const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
const allTunnels = isLive ? (greByServer[selectedServerId] ?? []) : greTunnels const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = isLive && (applyBusy || liveStale || liveLoadState === "error")
const filterRailItems = useMemo<ServerTileItem[]>(() => ( const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({ allServers.map((s) => ({
@@ -1427,21 +1452,6 @@ export default function FiltersPage() {
[rulesets, selectedServerId], [rulesets, selectedServerId],
) )
const fetchRouterCompare = useCallback(async () => {
if (!isLive || !selectedServerId) return
setRouterCompareLoading(true)
try {
const d = await apiFetch<{ byCommunity: Record<string, FilterRouterSyncStatus> }>(
`/api/filters/router-compare?serverId=${encodeURIComponent(selectedServerId)}`,
)
setRouterCompare({ serverId: selectedServerId, byCommunity: d.byCommunity })
} catch {
setRouterCompare(null)
} finally {
setRouterCompareLoading(false)
}
}, [isLive, selectedServerId, apiFetch])
const filteredRules = useMemo(() => { const filteredRules = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
if (!q) return currentRules if (!q) return currentRules
@@ -1453,68 +1463,94 @@ export default function FiltersPage() {
) )
}, [currentRules, search, communityNameMap]) }, [currentRules, search, communityNameMap])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => { const applyRules = useCallback(async (
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc)) serverId: string,
setRulesets(prev => { nextRules: FilterRule[],
const next = prev.map(rs => source: "apply" | "copy" = "apply",
rs.serverId === serverId ? { ...rs, rules: updater(rs.rules) } : rs ) => {
) const prev = rulesets
if (isLive) { setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
void apiFetch<{ ok: boolean }>("/api/filters/rules", { if (!isLive) return
method: "PUT", if (liveStale) {
body: JSON.stringify({ rulesets: next }), setRulesets(prev)
}).catch(() => {}) toast.error("Роутер недоступен — изменения заблокированы")
} return
return next }
}) setApplyBusy(true)
}, [isLive, apiFetch])
const syncFromRouter = useCallback(async () => {
if (!isLive || syncBusy) return
setSyncBusy("from")
try { try {
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", { const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
method: "POST", method: "PUT",
body: JSON.stringify({ serverId: selectedServerId }), body: JSON.stringify({ serverId, rules: nextRules, source }),
}) })
const fresh = await apiFetch<LiveFiltersResponse>("/api/filters/rules") if (res.rules) {
setRulesets(buildRulesets(allServers, fresh.rulesets)) setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
}
toast.success("Правила применены на роутер")
} catch (err) {
setRulesets(prev)
toast.error("Не удалось применить правила на роутер", { description: String(err) })
} finally {
setApplyBusy(false)
}
}, [isLive, apiFetch, rulesets, liveStale])
const refreshFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId || applyBusy) return
setApplyBusy(true)
try {
await loadLiveRules(selectedServerId)
await Promise.all([ await Promise.all([
ensureGreTunnels(selectedServerId), ensureGreTunnels(selectedServerId),
ensureRecursiveRoutes(selectedServerId), ensureRecursiveRoutes(selectedServerId),
]) ])
await fetchRouterCompare()
} finally { } finally {
setSyncBusy(null) setApplyBusy(false)
} }
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare]) }, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
const syncToRouter = useCallback(async () => { const loadRevisions = useCallback(async () => {
if (!isLive || syncBusy || !selectedServerId) return if (!isLive || !selectedServerId) return
setSyncBusy("to") setHistoryLoading(true)
try { try {
const res = await apiFetch<{ const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
ok: boolean `/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
updatedServers: number )
pushedRules: number setRevisions(res.revisions)
errors?: Array<{ serverId: number; error: string }>
}>("/api/filters/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
if (res.ok) {
toast.success(`Загружено правил на роутер: ${res.pushedRules}`)
} else {
const detail = res.errors?.[0]?.error ?? "неизвестная ошибка"
toast.error("Не удалось загрузить правила на роутер", { description: detail })
}
await fetchRouterCompare()
} catch (err) { } catch (err) {
toast.error("Не удалось загрузить правила на роутер", { description: String(err) }) toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally { } finally {
setSyncBusy(null) setHistoryLoading(false)
} }
}, [isLive, syncBusy, selectedServerId, apiFetch, fetchRouterCompare]) }, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
if (res.rules) {
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
} else {
await loadLiveRules(selectedServerId)
}
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, selectedServerId, apiFetch, loadLiveRules, loadRevisions])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
void applyRules(serverId, updater(current))
}, [rulesets, applyRules])
const openCreate = () => { const openCreate = () => {
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true) setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
@@ -1532,6 +1568,7 @@ export default function FiltersPage() {
} }
const handleSave = (form: RuleForm) => { const handleSave = (form: RuleForm) => {
if (mutationsLocked) return
const { gatewayKind: _gk, ...payload } = form const { gatewayKind: _gk, ...payload } = form
if (sheetMode === "create") { if (sheetMode === "create") {
updateRules(selectedServerId, rules => [ updateRules(selectedServerId, rules => [
@@ -1549,34 +1586,30 @@ export default function FiltersPage() {
setSheetOpen(false) setSheetOpen(false)
} }
const handleDelete = (id: string) => updateRules(selectedServerId, rules => rules.filter(r => r.id !== id)) const handleDelete = (id: string) => {
if (mutationsLocked) return
updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
}
const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => { const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
updateRules(targetServerId, existing => const existing = rulesets.find((rs) => rs.serverId === targetServerId)?.rules ?? []
mode === "replace" ? rules : [...existing, ...rules] const next = mode === "replace" ? rules : [...existing, ...rules]
) void applyRules(targetServerId, next, "copy")
}, [updateRules]) }, [rulesets, applyRules])
const handleMoveUp = (index: number) => { const handleMoveUp = (index: number) => {
if (index === 0) return if (mutationsLocked || index === 0) return
updateRules(selectedServerId, rules => { updateRules(selectedServerId, rules => {
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
}) })
} }
const handleMoveDown = (index: number) => { const handleMoveDown = (index: number) => {
if (mutationsLocked) return
updateRules(selectedServerId, rules => { updateRules(selectedServerId, rules => {
if (index >= rules.length - 1) return rules if (index >= rules.length - 1) return rules
const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n
}) })
} }
if (!selectedServer) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Нет доступных серверов
</div>
)
}
if (isLive && liveLoadState === "loading") { if (isLive && liveLoadState === "loading") {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
@@ -1586,6 +1619,14 @@ export default function FiltersPage() {
) )
} }
if (!selectedServer) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Нет доступных серверов
</div>
)
}
return ( return (
<> <>
<ServerRailLayout <ServerRailLayout
@@ -1604,35 +1645,25 @@ export default function FiltersPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={syncFromRouter} onClick={() => void refreshFromRouter()}
disabled={syncBusy !== null} disabled={applyBusy}
title="Синхронизация Router → БД" title="Прочитать актуальные правила с роутера"
> >
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"} <RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
Обновить
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={syncToRouter} onClick={() => {
disabled={syncBusy !== null} setHistoryOpen(true)
title="Синхронизация БД → Router" void loadRevisions()
}}
disabled={applyBusy}
title="История версий и откат на CHR"
> >
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"} <HistoryIcon className="size-4" />
</Button> История
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button> </Button>
</> </>
)} )}
@@ -1642,12 +1673,12 @@ export default function FiltersPage() {
<Button <Button
variant="outline" size="sm" variant="outline" size="sm"
onClick={() => setCopyOpen(true)} onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0} disabled={currentRules.length === 0 || mutationsLocked}
title="Копировать правила на другой сервер" title="Копировать правила на другой сервер"
> >
<CopyIcon className="size-4" />Копировать <CopyIcon className="size-4" />Копировать
</Button> </Button>
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Новое правило <PlusIcon className="size-4" />Новое правило
</Button> </Button>
</> </>
@@ -1662,6 +1693,12 @@ export default function FiltersPage() {
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках. Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div> </div>
)} )}
{isLive && liveStale && liveLoadState !== "error" && (
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
<AlertTriangleIcon className="size-3.5 shrink-0" />
Роутер недоступен показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
</div>
)}
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6"> <div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1"> <div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" /> <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
@@ -1745,15 +1782,7 @@ export default function FiltersPage() {
)}>{selectedServer.latency}мс</span> )}>{selectedServer.latency}мс</span>
)} )}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end"> <div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end">
{isLive && routerCompare?.serverId === selectedServerId && currentRules.length > 0 && ( {applyBusy && <span>Применение на роутер</span>}
<span className="font-mono tabular-nums">
роутер:{" "}
<span className="text-emerald-600 dark:text-emerald-500">
{Object.values(routerCompare.byCommunity).filter(s => s === "synced").length}
</span>
/{currentRules.length} совпало
</span>
)}
<span>{currentRules.length} правил</span> <span>{currentRules.length} правил</span>
</div> </div>
</div> </div>
@@ -1790,12 +1819,6 @@ export default function FiltersPage() {
serversList={allServers} serversList={allServers}
communityNameMap={communityNameMap} communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []} recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncByCommunity={
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity
}
isLive={isLive}
enableSorting={!!search} enableSorting={!!search}
onEdit={openEdit} onEdit={openEdit}
onDelete={handleDelete} onDelete={handleDelete}
@@ -1805,7 +1828,7 @@ export default function FiltersPage() {
)} )}
{/* add rule shortcut */} {/* add rule shortcut */}
<button onClick={openCreate} <button onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
Добавить правило для {selectedServer.name} Добавить правило для {selectedServer.name}
@@ -1854,6 +1877,17 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer} recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes} ensureRecursiveFor={ensureRecursiveRoutes}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История фильтров"
itemLabel="правил"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+67 -1
View File
@@ -49,12 +49,14 @@ import {
PowerIcon, CheckCircleIcon, PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
SlidersHorizontalIcon, RefreshCwIcon, SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -1947,6 +1949,10 @@ function FirewallPageInner() {
const [exportOpen, setExportOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false)
const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null) const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null)
const [addrSheetOpen, setAddrSheetOpen] = useState(false) const [addrSheetOpen, setAddrSheetOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async () => { const loadLive = useCallback(async () => {
if (!isLive) return if (!isLive) return
@@ -1970,6 +1976,42 @@ function FirewallPageInner() {
} }
}, [isLive, apiFetch]) }, [isLive, apiFetch])
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await apiFetch(
`/api/firewall/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
queueMicrotask(() => { queueMicrotask(() => {
@@ -2373,6 +2415,19 @@ function FirewallPageInner() {
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<HistoryIcon className="size-4" />
История
</Button>
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc <CodeXmlIcon className="size-4" />Экспорт .rsc
</Button> </Button>
@@ -2586,6 +2641,17 @@ function FirewallPageInner() {
onClose={() => setExportOpen(false)} onClose={() => setExportOpen(false)}
rules={familyRules} rules={familyRules}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История Firewall"
itemLabel="объектов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+289 -58
View File
@@ -13,11 +13,23 @@ import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose, SheetDescription, SheetFooter, SheetClose,
@@ -31,7 +43,7 @@ import {
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon, DatabaseIcon, HistoryIcon, TriangleAlertIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
@@ -56,6 +68,10 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
down: { label: "Down", dot: "bg-red-500" }, down: { label: "Down", dot: "bg-red-500" },
} }
function greStatusMeta(status: GreStatus | undefined) {
return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
}
// ─── RouterOS code generator ───────────────────────────────────────────────── // ─── RouterOS code generator ─────────────────────────────────────────────────
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string { function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
@@ -91,10 +107,10 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
lines.push(` address=${t.localInnerIp} \\`) lines.push(` address=${t.localInnerIp} \\`)
lines.push(` interface=${t.name}`) lines.push(` interface=${t.name}`)
// IPsec manual equivalent // IPsec: live CHR имеет только ipsec-secret; proposal — у моков/формы
if (t.ipsec) { if (t.ipsec?.encAlg && t.ipsec.authAlg) {
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1" const ikeMode = t.ipsec.ikeVersion === "ikev1" ? "ike1" : "ike2"
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none" const pfsGroup = t.ipsec.pfs ? (t.ipsec.dhGroup ?? "none") : "none"
lines.push("") lines.push("")
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────") lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
@@ -111,13 +127,16 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`) lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`) lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
lines.push(` pfs-group=${pfsGroup} \\`) lines.push(` pfs-group=${pfsGroup} \\`)
lines.push(` lifetime=${t.ipsec.lifetime}`) lines.push(` lifetime=${t.ipsec.lifetime ?? "1d"}`)
lines.push("") lines.push("")
lines.push(`/ip ipsec policy add \\`) lines.push(`/ip ipsec policy add \\`)
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`) lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
lines.push(` dst-address=${t.remoteAddress}/32 \\`) lines.push(` dst-address=${t.remoteAddress}/32 \\`)
lines.push(` proposal=${t.name} \\`) lines.push(` proposal=${t.name} \\`)
lines.push(` tunnel=yes`) lines.push(` tunnel=yes`)
} else if (t.ipsec) {
lines.push("")
lines.push("# IPsec: peer/policy создаёт RouterOS по ipsec-secret")
} }
return lines.join("\n") return lines.join("\n")
@@ -126,7 +145,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
// ─── small ui helpers ──────────────────────────────────────────────────────── // ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) { function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status] const s = greStatusMeta(status)
return ( return (
<span className="inline-flex items-center gap-1.5 text-sm"> <span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} /> <span className={`size-1.5 rounded-full ${s.dot}`} />
@@ -164,6 +183,7 @@ interface BackendServer {
interface GreTunnelsApiResponse { interface GreTunnelsApiResponse {
tunnels: GreTunnel[] tunnels: GreTunnel[]
failures?: Array<{ serverId: string; serverName?: string; error: string }>
} }
function makeApiFetch(backendUrl: string) { function makeApiFetch(backendUrl: string) {
@@ -237,7 +257,6 @@ export default function GrePage() {
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([]) const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
const [dataLoading, setDataLoading] = useState(false) const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null) const [dataError, setDataError] = useState<string | null>(null)
const [syncJhBusy, setSyncJhBusy] = useState(false)
const [pageTab, setPageTab] = useState<PageTab>("tunnels") const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all") const [tabFilter, setTabFilter] = useState<TabFilter>("all")
@@ -245,6 +264,15 @@ export default function GrePage() {
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [tunnelOpen, setTunnelOpen] = useState(false) const [tunnelOpen, setTunnelOpen] = useState(false)
const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create")
const [editingTunnel, setEditingTunnel] = useState<GreTunnel | null>(null)
const [pendingDelete, setPendingDelete] = useState<GreTunnel | null>(null)
const [mutateBusy, setMutateBusy] = useState(false)
const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [poolOpen, setPoolOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null) const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
@@ -261,14 +289,19 @@ export default function GrePage() {
try { try {
const [backendServers, greRes] = await Promise.all([ const [backendServers, greRes] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"), apiFetch<BackendServer[]>("/api/servers"),
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"), apiFetch<GreTunnelsApiResponse>("/api/gre/tunnels"),
]) ])
setLiveServers(backendServers.map(mapBackendToServer)) setLiveServers(backendServers.map(mapBackendToServer))
setLiveTunnels(greRes.tunnels) setLiveTunnels(greRes.tunnels)
setLiveStale(false)
if (greRes.failures?.length) {
toast.warning(
`Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) { } catch (e) {
setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveServers([]) setLiveStale(true)
setLiveTunnels([])
} finally { } finally {
setDataLoading(false) setDataLoading(false)
} }
@@ -280,6 +313,7 @@ export default function GrePage() {
setLiveServers([]) setLiveServers([])
setLiveTunnels([]) setLiveTunnels([])
setDataError(null) setDataError(null)
setLiveStale(false)
}) })
return return
} }
@@ -323,39 +357,186 @@ export default function GrePage() {
[displayPools], [displayPools],
) )
const syncJhToDb = useCallback(async () => { const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
if (!isLive || syncJhBusy) return const mutationsLocked = isLive && (mutateBusy || liveStale || historyRestoring)
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
if (jh.length === 0) { const loadRevisions = useCallback(async () => {
toast.info("Нет включённых Jump Host в списке серверов") if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await apiFetch(
`/api/gre/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
function tunnelWriteBody(form: typeof defaultTunnelForm) {
return {
serverId: form.serverId,
name: form.name.trim(),
localAddress: form.localAddress.trim() || undefined,
remoteAddress: form.remoteAddress.trim(),
localInnerIp: form.localInnerIp.trim() || undefined,
remoteInnerIp: form.remoteInnerIp.trim() || undefined,
comment: form.comment || undefined,
enabled: form.enabled,
mtu: form.mtu,
keepaliveInterval: form.keepaliveInterval,
keepaliveRetries: form.keepaliveRetries,
dscp: form.dscp,
clampTcpMss: form.clampTcpMss,
allowFastPath: form.allowFastPath,
ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : undefined,
}
}
async function submitTunnel() {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
return return
} }
setSyncJhBusy(true) if (liveStale) {
const errors: string[] = [] toast.error("Роутер недоступен — изменения заблокированы")
try { return
for (const s of jh) {
try {
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: s.id }),
})
} catch (e) {
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
}
}
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
setLiveTunnels(fresh.tunnels)
if (errors.length) {
toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
} else {
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
} finally {
setSyncJhBusy(false)
} }
}, [isLive, syncJhBusy, apiFetch, displayServers]) if (!tForm.name.trim() || !tForm.serverId || !tForm.remoteAddress.trim()) {
toast.error("Заполните имя, сервер и удалённый адрес")
return
}
if (tForm.ipsecEnabled && tForm.ipsecSecret.trim().length < 8) {
toast.error("Для IPsec нужен PSK не короче 8 символов")
return
}
setMutateBusy(true)
try {
if (tunnelMode === "edit" && editingTunnel) {
await apiFetch("/api/gre/tunnels", {
method: "PATCH",
body: JSON.stringify({
...tunnelWriteBody(tForm),
rosId: editingTunnel.id,
name: editingTunnel.name,
}),
})
toast.success(`Туннель ${tForm.name} обновлён`)
} else {
await apiFetch("/api/gre/tunnels", {
method: "POST",
body: JSON.stringify(tunnelWriteBody(tForm)),
})
toast.success(`Туннель ${tForm.name} создан`)
}
setTunnelOpen(false)
setEditingTunnel(null)
await loadLive()
} catch (err) {
toast.error("Не удалось сохранить туннель", { description: String(err) })
} finally {
setMutateBusy(false)
}
}
async function toggleTunnel(t: GreTunnel) {
if (!isLive || mutationsLocked) return
setMutateBusy(true)
try {
await apiFetch("/api/gre/tunnels", {
method: "PATCH",
body: JSON.stringify({
serverId: t.serverId,
rosId: t.id,
name: t.name,
enabled: !t.enabled,
remoteAddress: t.remoteAddress,
}),
})
toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`)
await loadLive()
} catch (err) {
toast.error("Не удалось изменить туннель", { description: String(err) })
} finally {
setMutateBusy(false)
}
}
async function confirmDeleteTunnel() {
const t = pendingDelete
if (!t || !isLive) return
setMutateBusy(true)
try {
await apiFetch("/api/gre/tunnels", {
method: "DELETE",
body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }),
})
toast.success(`Удалён ${t.name}`)
setPendingDelete(null)
await loadLive()
} catch (err) {
toast.error("Не удалось удалить туннель", { description: String(err) })
} finally {
setMutateBusy(false)
}
}
function openCreateTunnel() {
setTunnelMode("create")
setEditingTunnel(null)
setTForm({
...defaultTunnelForm,
serverId: selectedServerId === ALL_SERVERS_ID ? "" : selectedServerId,
})
setTunnelOpen(true)
}
function openEditTunnel(t: GreTunnel) {
setTunnelMode("edit")
setEditingTunnel(t)
setTForm({
...defaultTunnelForm,
name: t.name,
serverId: t.serverId,
localAddress: t.localAddress === "0.0.0.0" ? "" : t.localAddress,
remoteAddress: t.remoteAddress,
poolId: t.poolId === "live" ? "" : t.poolId,
localInnerIp: t.localInnerIp,
remoteInnerIp: t.remoteInnerIp,
comment: t.comment,
enabled: t.enabled,
ipsecEnabled: !!t.ipsec,
ipsecSecret: t.ipsec?.secret ?? "",
mtu: t.mtu,
keepaliveInterval: t.keepaliveInterval,
keepaliveRetries: t.keepaliveRetries,
dscp: String(t.dscp),
clampTcpMss: t.clampTcpMss,
allowFastPath: t.allowFastPath,
})
setTunnelOpen(true)
}
useEffect(() => { useEffect(() => {
if (dataError) toast.error(dataError) if (dataError) toast.error(dataError)
@@ -403,6 +584,14 @@ export default function GrePage() {
showAll showAll
allCount={displayServers.length} allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0} loading={isLive && dataLoading && displayServers.length === 0}
banner={
isLive && liveStale ? (
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
<TriangleAlertIcon className="size-3.5 shrink-0" />
Роутер недоступен показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
</div>
) : null
}
header={ header={
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]} crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
@@ -422,14 +611,17 @@ export default function GrePage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { void syncJhToDb() }} onClick={() => {
disabled={!isLive || syncJhBusy || dataLoading} setHistoryOpen(true)
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE" void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
> >
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} /> <HistoryIcon className="size-4" />
JH БД История
</Button> </Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}> <Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
<PlusIcon className="size-4" />Добавить туннель <PlusIcon className="size-4" />Добавить туннель
</Button> </Button>
</> </>
@@ -521,6 +713,10 @@ export default function GrePage() {
servers={displayServers} servers={displayServers}
pools={displayPools} pools={displayPools}
onCodePreview={setCodePreviewTunnel} onCodePreview={setCodePreviewTunnel}
onEdit={openEditTunnel}
onToggle={(t) => { void toggleTunnel(t) }}
onDelete={setPendingDelete}
mutationsLocked={mutationsLocked}
/> />
</DataPageCard> </DataPageCard>
)} )}
@@ -548,7 +744,7 @@ export default function GrePage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{poolTunnels.map((t, tunnelIndex) => ( {poolTunnels.map((t, tunnelIndex) => (
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs"> <div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} /> <span className={`size-1.5 rounded-full ${greStatusMeta(t.status).dot}`} />
<span className="font-mono font-medium">{t.name}</span> <span className="font-mono font-medium">{t.name}</span>
<span className="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span> <span className="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span>
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />} {t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
@@ -609,8 +805,8 @@ export default function GrePage() {
codePreviewTunnel ? ( codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0"> <div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} /> <span className={`size-1.5 rounded-full ${greStatusMeta(codePreviewTunnel.status).dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label} {greStatusMeta(codePreviewTunnel.status).label}
</span> </span>
<span className="text-muted-foreground">·</span> <span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span> <span>{serverById[codePreviewTunnel.serverId]?.name}</span>
@@ -625,7 +821,7 @@ export default function GrePage() {
<span className="text-muted-foreground">·</span> <span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success"> <span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" /> <LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]} IPsec {codePreviewTunnel.ipsec.ikeVersion ? IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion] : "PSK"}
</span> </span>
</> </>
) : null} ) : null}
@@ -638,18 +834,18 @@ export default function GrePage() {
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}> <Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый GRE-туннель</SheetTitle> <SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription> <SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5"> <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb"> <FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} /> <Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} disabled={tunnelMode === "edit"} onChange={(e) => setT("name", e.target.value)} />
</FormField> </FormField>
<FormField label="Сервер (MikroTik)" required> <FormField label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} <select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"> className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер</option> <option value="" disabled>Выбрать сервер</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)} {displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
@@ -676,7 +872,7 @@ export default function GrePage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle> <SectionTitle>Внутренний IP</SectionTitle>
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок"> <FormField label="IP-пул" hint="Необязательно — внутренний IP можно указать вручную">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)} <select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"> className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул</option> <option value="" disabled>Выбрать пул</option>
@@ -798,7 +994,9 @@ export default function GrePage() {
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose> <SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button> <Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy}>
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -845,6 +1043,39 @@ export default function GrePage() {
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История GRE"
itemLabel="туннелей"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить GRE-туннель?</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete
? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteTunnel()}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
) )
} }
+11 -3
View File
@@ -67,6 +67,7 @@ import {
CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon, CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label"
import Link from "next/link" import Link from "next/link"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
@@ -820,9 +821,15 @@ function ServicePathList({
{paths.map((p) => { {paths.map((p) => {
const rowKey = servicePathKey(p) const rowKey = servicePathKey(p)
const via = servers.find((s) => s.id === p.viaId) const via = servers.find((s) => s.id === p.viaId)
const viaLabel = via?.site || p.viaName const en = servers.find((s) => s.id === p.enId)
const svc = services.find((s) => s.id === p.serviceId) const svc = services.find((s) => s.id === p.serviceId)
const mid = viaMode === "via" ? viaLabel : (svc?.label ?? p.serviceId) const label = formatServicePathLabel(p, viaMode, {
viaName: via?.name,
viaSite: via?.site,
enName: en?.name,
serviceLabel: svc?.label,
})
const title = formatServicePathTitle(label, svc?.label ?? p.serviceId)
const active = Boolean( const active = Boolean(
highlight highlight
&& highlight.viaId === p.viaId && highlight.viaId === p.viaId
@@ -833,13 +840,14 @@ function ServicePathList({
<button <button
key={rowKey} key={rowKey}
type="button" type="button"
title={title}
onClick={() => onToggle(p)} onClick={() => onToggle(p)}
className={cn( className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors", "flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
active ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50", active ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
)} )}
> >
<span className="font-mono truncate min-w-0">{p.clientName} · {mid}</span> <span className="font-mono truncate min-w-0">{label}</span>
<span className="font-mono text-emerald-400 tabular-nums shrink-0"> <span className="font-mono text-emerald-400 tabular-nums shrink-0">
{formatNetflowRate({ bytes: p.bytes, bps: p.bps, bpsFwd: p.bps, bpsRev: 0 })} {formatNetflowRate({ bytes: p.bytes, bps: p.bps, bpsFwd: p.bps, bpsRev: 0 })}
</span> </span>
+29 -3
View File
@@ -122,11 +122,17 @@ interface BackendBfdSession {
packetsRx: number; packetsTx: number; stateChanges: number packetsRx: number; packetsTx: number; stateChanges: number
} }
interface BackendOspfRoute {
id: string; serverId: number; serverName: string; serverSite: string
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
}
interface BackendOspfAll { interface BackendOspfAll {
neighbors: BackendNeighbor[] neighbors: BackendNeighbor[]
interfaces: BackendInterface[] interfaces: BackendInterface[]
instances: BackendInstance[] instances: BackendInstance[]
bfdSessions: BackendBfdSession[] bfdSessions: BackendBfdSession[]
routes?: BackendOspfRoute[]
} }
function isRefInterfaceName(name: string): boolean { function isRefInterfaceName(name: string): boolean {
@@ -213,6 +219,22 @@ function backendToBfdSession(b: BackendBfdSession): BfdSession {
} }
} }
function backendToRoute(b: BackendOspfRoute): OspfRoute {
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2"]
const type = allowed.includes(b.type) ? b.type : "O"
return {
id: `${b.serverId}-${b.id}`,
destination: b.destination,
type,
cost: b.cost,
nextHop: b.nextHop,
via: b.via,
serverId: String(b.serverId),
serverLabel: b.serverName,
area: b.area || "—",
}
}
// ─── mock data ──────────────────────────────────────────────────────────────── // ─── mock data ────────────────────────────────────────────────────────────────
const COST_STEP = 10 const COST_STEP = 10
@@ -1172,7 +1194,7 @@ export default function OspfPage() {
}, [isLive, backendUrl, fetchTick]) }, [isLive, backendUrl, fetchTick])
// Derive frontend types from backend data or use mocks // Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => { const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
if (isLive && liveData) { if (isLive && liveData) {
// Build interface→cost map for neighbor cost lookup // Build interface→cost map for neighbor cost lookup
const ifaceMap = new Map<string, number>() const ifaceMap = new Map<string, number>()
@@ -1185,6 +1207,7 @@ export default function OspfPage() {
.filter((item) => !isRefInterfaceName(item.interfaceName)) .filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap)) const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession) const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
const routes = (liveData.routes ?? []).map(backendToRoute)
// Build routerIds from instances // Build routerIds from instances
const routerIds: Record<string, string> = {} const routerIds: Record<string, string> = {}
@@ -1195,7 +1218,7 @@ export default function OspfPage() {
} }
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors) const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes }
} }
if (isLive) { if (isLive) {
return { return {
@@ -1205,6 +1228,7 @@ export default function OspfPage() {
graphEdges: [], graphEdges: [],
routerIds: {}, routerIds: {},
bfdSessions: [], bfdSessions: [],
routes: [],
} }
} }
return { return {
@@ -1214,6 +1238,7 @@ export default function OspfPage() {
graphEdges: MOCK_GRAPH_EDGES, graphEdges: MOCK_GRAPH_EDGES,
routerIds: MOCK_ROUTER_IDS, routerIds: MOCK_ROUTER_IDS,
bfdSessions: MOCK_BFD, bfdSessions: MOCK_BFD,
routes: MOCK_ROUTES,
} }
}, [isLive, liveData]) }, [isLive, liveData])
@@ -1257,6 +1282,7 @@ export default function OspfPage() {
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => ( const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => { ospfServers.map((s) => {
@@ -1398,7 +1424,7 @@ export default function OspfPage() {
routerIds={routerIds} routerIds={routerIds}
/> />
)} )}
{activeTab === "routes" && <RoutesTab routes={isLive ? [] : MOCK_ROUTES} />} {activeTab === "routes" && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />} {activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div> </div>
+119 -58
View File
@@ -18,9 +18,12 @@ import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data" import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react" import { PlusIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
import { toast } from "sonner"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { type ServerTileItem } from "@/components/server-tile-rail" import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer { interface BackendServer {
@@ -364,7 +367,7 @@ export default function RecursiveRoutesPage() {
const [servers, setServers] = useState<Server[]>([]) const [servers, setServers] = useState<Server[]>([])
const [selectedServerId, setSelectedServerId] = useState<string>("") const [selectedServerId, setSelectedServerId] = useState<string>("")
const [rows, setRows] = useState<RecursiveRouteRow[]>([]) const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null) const [busy, setBusy] = useState<"load" | "apply" | null>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
@@ -373,6 +376,11 @@ export default function RecursiveRoutesPage() {
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([]) const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null) const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null) const [opError, setOpError] = useState<string | null>(null)
const [liveStale, setLiveStale] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */ /** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false) const [liveServerListReady, setLiveServerListReady] = useState(false)
@@ -407,10 +415,13 @@ export default function RecursiveRoutesPage() {
setOpError(null) setOpError(null)
setBusy("load") setBusy("load")
try { try {
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`) const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
`/api/recursive-routes?serverId=${selectedServerId}`,
)
setRows(res.routes) setRows(res.routes)
setLiveStale(Boolean(res.stale))
} catch (e) { } catch (e) {
setRows([]) setLiveStale(true)
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты") setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
} finally { } finally {
setBusy(null) setBusy(null)
@@ -437,56 +448,65 @@ export default function RecursiveRoutesPage() {
void loadGateways() void loadGateways()
}, [loadGateways]) }, [loadGateways])
const saveToDb = useCallback(async () => { const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId || liveStale) return
const prev = rows
setRows(next)
setOpError(null) setOpError(null)
setBusy("save") setBusy("apply")
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", { const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
method: "PUT", method: "PUT",
body: JSON.stringify({ serverId: selectedServerId, routes: rows }), body: JSON.stringify({ serverId: selectedServerId, routes: next }),
}) })
await loadRoutes() setRows(res.routes ?? next)
setLiveStale(false)
toast.success("Маршруты применены на роутер")
} catch (e) { } catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД") setRows(prev)
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
setOpError(msg)
toast.error(msg)
} finally { } finally {
setBusy(null) setBusy(null)
} }
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes]) }, [isLive, selectedServerId, liveStale, rows, apiFetch])
const syncFromRouter = useCallback(async () => { const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return if (!isLive || !selectedServerId) return
setOpError(null) setHistoryLoading(true)
setBusy("from")
try { try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", { const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
method: "POST", `/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
body: JSON.stringify({ serverId: selectedServerId }), )
}) setRevisions(res.revisions)
await loadRoutes()
} catch (e) { } catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера") toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
setRevisions([])
} finally { } finally {
setBusy(null) setHistoryLoading(false)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("to")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
} finally {
setBusy(null)
} }
}, [isLive, selectedServerId, apiFetch]) }, [isLive, selectedServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !selectedServerId) return
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
setRows(res.routes ?? [])
setLiveStale(false)
toast.success("Версия применена на роутер")
await loadRevisions()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось откатить")
} finally {
setHistoryRestoring(false)
}
}, [isLive, selectedServerId, apiFetch, loadRevisions])
function groupKeyOf(row: RecursiveRouteRow): string { function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase() return row.dstAddress.trim().toLowerCase()
} }
@@ -529,22 +549,26 @@ export default function RecursiveRoutesPage() {
disabled: false, disabled: false,
country: ep.country || inferCountry(ep.gateway) || "", country: ep.country || inferCountry(ep.gateway) || "",
}) })
let next: RecursiveRouteRow[]
if (sheetMode === "create") { if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded]) next = [...rows, ...expanded]
} else if (editingGroupKey) { } else if (editingGroupKey) {
setRows(prev => { const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey) const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`)) next = [...kept, ...expanded]
return [...kept, ...expanded] } else {
}) setSheetOpen(false)
return
} }
setSheetOpen(false) setSheetOpen(false)
void applyRoutes(next)
} }
const currentServer = servers.find(s => s.id === selectedServerId) const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = !isLive || busy !== null || liveStale
const rrRailItems = useMemo<ServerTileItem[]>(() => ( const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({ servers.map((s) => ({
id: s.id, id: s.id,
@@ -602,16 +626,33 @@ export default function RecursiveRoutesPage() {
actions={ actions={
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}> {isLive && (
{busy === "from" ? "Синхронизация..." : "Router => DB"} <>
</Button> <Button
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}> variant="outline"
{busy === "to" ? "Применение..." : "DB => Router"} size="sm"
</Button> onClick={() => void loadRoutes()}
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}> disabled={busy !== null}
<SaveIcon className="size-4" />Сохранить в БД title="Прочитать маршруты с роутера"
</Button> >
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}> <RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={busy !== null}
>
<HistoryIcon className="size-4" />
История
</Button>
</>
)}
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Добавить <PlusIcon className="size-4" />Добавить
</Button> </Button>
</> </>
@@ -643,6 +684,12 @@ export default function RecursiveRoutesPage() {
{opError} {opError}
</div> </div>
)} )}
{liveStale && (
<div className="w-full text-xs text-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon className="size-3.5 shrink-0" />
Роутер недоступен показан кэш. Изменения заблокированы.
</div>
)}
</div> </div>
} }
> >
@@ -679,10 +726,13 @@ export default function RecursiveRoutesPage() {
expandedKey={expandedGroupKey} expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey} onExpandedChange={setExpandedGroupKey}
onEdit={openEdit} onEdit={openEdit}
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))} onDelete={(g) => {
if (mutationsLocked) return
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/> />
<button onClick={openCreate} <button onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
Добавить маршрут Добавить маршрут
</button> </button>
@@ -698,6 +748,17 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)} onClose={() => setSheetOpen(false)}
gateways={gatewayOptions} gateways={gatewayOptions}
/> />
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+48 -12
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState } from "react" import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { import {
ActivityIcon, ActivityIcon,
DatabaseIcon, DatabaseIcon,
@@ -111,6 +111,10 @@ function readView(sp: URLSearchParams): "explore" | "pivot" {
return sp.get("view") === "pivot" ? "pivot" : "explore" return sp.get("view") === "pivot" ? "pivot" : "explore"
} }
function readPlanes(sp: URLSearchParams): "unique" | "all" {
return sp.get("planes") === "all" ? "all" : "unique"
}
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim { function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
const v = sp.get(key) const v = sp.get(key)
return v && isStatisticsPivotDim(v) ? v : fallback return v && isStatisticsPivotDim(v) ? v : fallback
@@ -148,7 +152,7 @@ function filtersToSlices(filters: Filter[]): CubeSlices {
return next return next
} }
function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery { function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
const serverId = slices.serverId ? Number(slices.serverId) : undefined const serverId = slices.serverId ? Number(slices.serverId) : undefined
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
return { return {
@@ -160,6 +164,7 @@ function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
country: slices.country && slices.country.length === 2 ? slices.country : undefined, country: slices.country && slices.country.length === 2 ? slices.country : undefined,
service: slices.service, service: slices.service,
asn: Number.isFinite(asn) ? asn : undefined, asn: Number.isFinite(asn) ? asn : undefined,
planes,
} }
} }
@@ -249,8 +254,7 @@ function chipList(slices: CubeSlices): { key: string; label: string }[] {
return chips return chips
} }
export default function StatisticsPage() { function StatisticsPageInner() {
const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { mode, backendUrl, prefsHydrated } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
@@ -260,6 +264,7 @@ export default function StatisticsPage() {
const filters = useMemo(() => slicesToFilters(slices), [slices]) const filters = useMemo(() => slicesToFilters(slices), [slices])
const dim = useMemo(() => readDim(searchParams), [searchParams]) const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams]) const view = useMemo(() => readView(searchParams), [searchParams])
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams]) const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams]) const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
@@ -276,9 +281,10 @@ export default function StatisticsPage() {
else sp.delete(k) else sp.delete(k)
} }
const qs = sp.toString() const qs = sp.toString()
router.replace(qs ? `/statistics?${qs}` : "/statistics") if (qs === searchParams.toString()) return
window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics")
}, },
[router, searchParams], [searchParams],
) )
const setRange = useCallback( const setRange = useCallback(
@@ -309,7 +315,7 @@ export default function StatisticsPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const query = toQuery(range, slices) const query = toQuery(range, slices, planes)
const dto = await getStatistics(backendUrl, query) const dto = await getStatistics(backendUrl, query)
if (!cancelled) setData(dto) if (!cancelled) setData(dto)
if (view === "pivot" && pivotRow !== pivotCol) { if (view === "pivot" && pivotRow !== pivotCol) {
@@ -334,14 +340,15 @@ export default function StatisticsPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol]) }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes])
const viewData = isLive ? data : EMPTY const viewData = isLive ? data : EMPTY
const sliced = hasAnySlice(slices) const sliced = hasAnySlice(slices)
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0) const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) { function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
if (kind === "interfaces" && row.label.includes("· дубль")) return
setSlices(applyDimValue(slices, kind, row.id)) setSlices(applyDimValue(slices, kind, row.id))
} }
@@ -393,6 +400,15 @@ export default function StatisticsPage() {
</Alert> </Alert>
) : null} ) : null}
{!slices.serverId && isLive && !emptyCube ? (
<Alert>
<AlertTitle>Уникальный объём</AlertTitle>
<AlertDescription>
Объём трафик клиентов на GRE/WG, без повторного учёта JHEN и WAN.
</AlertDescription>
</Alert>
) : null}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка трафика" aria-label="Сводка трафика"
isLoading={loading} isLoading={loading}
@@ -402,7 +418,7 @@ export default function StatisticsPage() {
id: "bytes", id: "bytes",
label: "Объём", label: "Объём",
value: formatBytes(kpis.bytes), value: formatBytes(kpis.bytes),
hint: kpis.topCountry ? `топ: ${kpis.topCountry}` : undefined, hint: "GRE/WG клиентов, без hops",
icon: <DatabaseIcon />, icon: <DatabaseIcon />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -432,7 +448,11 @@ export default function StatisticsPage() {
id: "servers", id: "servers",
label: "Серверы", label: "Серверы",
value: String(kpis.servers), value: String(kpis.servers),
hint: kpis.ifaces ? `${kpis.ifaces} iface` : undefined, hint: slices.serverId
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
: planes === "all"
? "WAN и дубли в списке"
: "без WAN и overlay",
icon: <ServerIcon />, icon: <ServerIcon />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -453,6 +473,14 @@ export default function StatisticsPage() {
{ value: "pivot", label: "Сводка" }, { value: "pivot", label: "Сводка" },
]} ]}
/> />
<SegmentedControl
value={planes}
onChange={(next) => replaceParams({ planes: next === "all" ? "all" : undefined })}
options={[
{ value: "unique", label: "Уникальный" },
{ value: "all", label: "Все плоскости" },
]}
/>
{view === "explore" && !sliced ? ( {view === "explore" && !sliced ? (
<DimensionSelect <DimensionSelect
label="Критерий" label="Критерий"
@@ -520,3 +548,11 @@ export default function StatisticsPage() {
</div> </div>
) )
} }
export default function StatisticsPage() {
return (
<Suspense fallback={null}>
<StatisticsPageInner />
</Suspense>
)
}
+190 -45
View File
@@ -1,30 +1,63 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data" import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data" import type { Server, VxlanTunnel } from "@/lib/data"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { import {
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon, NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ────────────────────────────────────────────────────────────────── interface BackendServer {
id: number
function serverFor(id: string) { name: string
return servers.find((s) => s.id === id) host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
} }
// ─── RSC generator ─────────────────────────────────────────────────────────── interface VxlanApiResponse {
tunnels: VxlanTunnel[]
}
function generateVxlanRsc(t: VxlanTunnel): string { function mapBackendServer(s: BackendServer): Server {
const srv = serverFor(t.serverId) return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): string {
const srv = serverById[t.serverId]
const lines: string[] = [] const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`) lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
@@ -42,7 +75,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
if (!t.enabled) lines.push(` disabled=yes \\`) if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``) lines.push(``)
// FDB entries for remote VTEPs
for (const vtep of t.remoteVteps) { for (const vtep of t.remoteVteps) {
lines.push(`/interface/vxlan/vteps/add \\`) lines.push(`/interface/vxlan/vteps/add \\`)
lines.push(` interface=${t.name} \\`) lines.push(` interface=${t.name} \\`)
@@ -50,7 +82,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
lines.push(``) lines.push(``)
} }
// Bridge
lines.push(`# Добавить в bridge:`) lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`) lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`) lines.push(` bridge=bridge-overlay \\`)
@@ -59,12 +90,18 @@ function generateVxlanRsc(t: VxlanTunnel): string {
return lines.join("\n") return lines.join("\n")
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── function ExportSheet({
open, tunnel, onClose, serverById,
function ExportSheet({ open, tunnel, onClose }: { }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void open: boolean
tunnel: VxlanTunnel | null
onClose: () => void
serverById: Record<string, Server>
}) { }) {
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) const code = useMemo(
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
[tunnel, serverById],
)
return ( return (
<CodeExportSheet <CodeExportSheet
@@ -84,46 +121,156 @@ function ExportSheet({ open, tunnel, onClose }: {
) )
} }
// ─── Export Sheet ─────────────────────────────────────────────────────────────
export default function VxlanPage() { export default function VxlanPage() {
const [search, setSearch] = useState("") const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null) const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
setLiveError(null)
try {
const [tunnelsRes, serversRes] = await Promise.all([
requestJson<VxlanApiResponse>(backendUrl, "/api/vxlan"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveTunnels(tunnelsRes.tunnels ?? [])
setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveTunnels([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveTunnels([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scopedTunnels = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
}, [displayTunnels, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
}))
), [displayServers, displayTunnels])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return vxlanTunnels if (!search) return scopedTunnels
const q = search.toLowerCase() const q = search.toLowerCase()
return vxlanTunnels.filter((t) => return scopedTunnels.filter((t) =>
t.name.includes(q) || t.name.includes(q) ||
String(t.vni).includes(q) || String(t.vni).includes(q) ||
t.vtepIp.includes(q) || t.vtepIp.includes(q) ||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false) (serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
) )
}, [search]) }, [search, scopedTunnels, serverById])
const upCount = vxlanTunnels.filter((t) => t.status === "up").length const upCount = scopedTunnels.filter((t) => t.status === "up").length
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<Button size="sm"> onSelect={setSelectedServerId}
<PlusIcon className="size-4" />Новый VXLAN showAll
</Button> allCount={displayServers.length}
} loading={isLive && loading && displayServers.length === 0}
/> header={
<PageHeader
<div className="flex-1 overflow-y-auto p-6"> crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{isLive && liveError && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && displayTunnels.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На опрошенных серверах нет VXLAN-интерфейсов
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка VXLAN" aria-label="Сводка VXLAN"
items={[ items={[
{ {
id: "tunnels", id: "tunnels",
label: "Туннелей", label: "Туннелей",
value: vxlanTunnels.length, value: scopedTunnels.length,
icon: <NetworkIcon className="size-4" />, icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground", iconClassName: "text-muted-foreground",
}, },
@@ -144,14 +291,13 @@ export default function VxlanPage() {
{ {
id: "servers", id: "servers",
label: "Серверов", label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size, value: new Set(scopedTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />, icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary", iconClassName: "text-primary",
}, },
]} ]}
/> />
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" /> <NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div> <div>
@@ -163,7 +309,6 @@ export default function VxlanPage() {
</div> </div>
</div> </div>
{/* Table */}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
@@ -173,12 +318,11 @@ export default function VxlanPage() {
/> />
<VxlanDataGrid <VxlanDataGrid
tunnels={filtered} tunnels={filtered}
servers={servers} servers={displayServers}
onExport={setExportTunnel} onExport={setExportTunnel}
/> />
</DataPageCard> </DataPageCard>
{/* Reference */}
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
@@ -232,13 +376,14 @@ export default function VxlanPage() {
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
<ExportSheet <ExportSheet
open={!!exportTunnel} open={!!exportTunnel}
tunnel={exportTunnel} tunnel={exportTunnel}
onClose={() => setExportTunnel(null)} onClose={() => setExportTunnel(null)}
serverById={serverById}
/> />
</div> </>
) )
} }
+71 -1
View File
@@ -57,10 +57,12 @@ import {
type ServerTileItem, type ServerTileItem,
} from "@/components/server-tile-rail" } from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
} from "lucide-react" } from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli" type WgWorkspaceTab = "interfaces" | "peers" | "cli"
@@ -188,6 +190,10 @@ export default function WireGuardPage() {
const [exportPeerId, setExportPeerId] = useState<string | null>(null) const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null) const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null) const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [liveExport, setLiveExport] = useState<{ const [liveExport, setLiveExport] = useState<{
rsc?: string rsc?: string
conf?: string conf?: string
@@ -242,6 +248,44 @@ export default function WireGuardPage() {
? selectedServerId ? selectedServerId
: ALL_SERVERS_ID : ALL_SERVERS_ID
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
setHistoryLoading(true)
try {
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
backendUrl,
`/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} finally {
setHistoryLoading(false)
}
}, [isLive, historyServerId, backendUrl])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
setHistoryRestoring(true)
try {
await requestJson(
backendUrl,
`/api/wireguard/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
} catch (err) {
toast.error("Не удалось откатить", { description: String(err) })
} finally {
setHistoryRestoring(false)
}
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
const scopedIfaces = useMemo(() => { const scopedIfaces = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId) return displayIfaces.filter((i) => i.serverId === effectiveServerId)
@@ -544,6 +588,7 @@ export default function WireGuardPage() {
<> <>
<ServerRailMobileButton /> <ServerRailMobileButton />
{isLive && ( {isLive && (
<>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -553,6 +598,20 @@ export default function WireGuardPage() {
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить Обновить
</Button> </Button>
<Button
size="sm"
variant="outline"
disabled={loading || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
>
<HistoryIcon className="size-4" />
История
</Button>
</>
)} )}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}> <Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
@@ -822,6 +881,17 @@ export default function WireGuardPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История WireGuard"
itemLabel="интерфейсов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</> </>
) )
} }
+16
View File
@@ -0,0 +1,16 @@
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
-- Retention — prune в сервисе (последние 50 на пару server+section).
CREATE TABLE IF NOT EXISTS config_revisions (
id TEXT PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
section TEXT NOT NULL,
source TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
ON config_revisions (server_id, section, created_at DESC);
@@ -0,0 +1,7 @@
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
-- Needed to rebuild facts with the same internet dest as the network map.
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
+6 -3
View File
@@ -12,14 +12,17 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts", "db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/statistics-aggregate.test.ts", "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts", "test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts" "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
}, },
"dependencies": { "dependencies": {
+30 -1
View File
@@ -9,6 +9,8 @@ if (!(await withPgOrSkip())) {
process.exit(0) process.exit(0)
} }
await applySqlMigrations(pool)
{ {
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`) const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
assert.ok(rows[0]) assert.ok(rows[0])
@@ -114,13 +116,17 @@ if (!(await withPgOrSkip())) {
SELECT column_name, udt_name SELECT column_name, udt_name
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'flow_buckets' WHERE table_schema = 'public' AND table_name = 'flow_buckets'
AND column_name IN ('src', 'dst', 'next_hop', 'proto') AND column_name IN ('src', 'dst', 'next_hop', 'proto', 'nat_src', 'nat_dst', 'nat_src_port', 'nat_dst_port')
`) `)
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name])) const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
assert.equal(by.src, "inet") assert.equal(by.src, "inet")
assert.equal(by.dst, "inet") assert.equal(by.dst, "inet")
assert.equal(by.next_hop, "inet") assert.equal(by.next_hop, "inet")
assert.equal(by.proto, "int2") assert.equal(by.proto, "int2")
assert.equal(by.nat_src, "inet")
assert.equal(by.nat_dst, "inet")
assert.equal(by.nat_src_port, "int4")
assert.equal(by.nat_dst_port, "int4")
} }
{ {
@@ -163,6 +169,29 @@ if (!(await withPgOrSkip())) {
await dbQuery(`DELETE FROM servers WHERE name = 'pg-wipe-idempotent'`) await dbQuery(`DELETE FROM servers WHERE name = 'pg-wipe-idempotent'`)
} }
{
const mig = await dbQuery<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = '0006_config_revisions'`,
)
assert.equal(mig.rows.length, 1, "0006 применена")
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
SELECT column_name, udt_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'config_revisions'
`)
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
assert.equal(by.payload, "jsonb")
assert.equal(by.fingerprint, "text")
assert.equal(by.section, "text")
}
{
const mig = await dbQuery<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = '0007_flow_buckets_nat'`,
)
assert.equal(mig.rows.length, 1, "0007 применена")
}
{ {
const marker = await dbQuery<{ sqlite_imported_at: string | null }>( const marker = await dbQuery<{ sqlite_imported_at: string | null }>(
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`, `SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
+18
View File
@@ -93,6 +93,19 @@ export const filterRules = pgTable("filter_rules", {
index("idx_filter_rules_server_sort").on(t.serverId, t.sortOrder), index("idx_filter_rules_server_sort").on(t.serverId, t.sortOrder),
]) ])
export const configRevisions = pgTable("config_revisions", {
id: text("id").primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "gre"] }).notNull(),
source: text("source", { enum: ["apply", "rollback", "observed", "copy"] }).notNull(),
fingerprint: text("fingerprint").notNull(),
payload: jsonb("payload").$type<unknown>().notNull().default(sql`'[]'::jsonb`),
note: text("note"),
createdAt: ts("created_at").notNull().defaultNow(),
}, (t) => [
index("idx_config_revisions_server_section_created").on(t.serverId, t.section, t.createdAt),
])
export const recursiveRoutes = pgTable("recursive_routes", { export const recursiveRoutes = pgTable("recursive_routes", {
id: idIdentity().primaryKey(), id: idIdentity().primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }), serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
@@ -253,6 +266,10 @@ export const flowBuckets = pgTable("flow_buckets", {
nextHop: inet("next_hop"), nextHop: inet("next_hop"),
flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0), flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0),
flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0), flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0),
natSrc: inet("nat_src"),
natDst: inet("nat_dst"),
natSrcPort: integer("nat_src_port").notNull().default(0),
natDstPort: integer("nat_dst_port").notNull().default(0),
}, (t) => [ }, (t) => [
primaryKey({ primaryKey({
name: "flow_buckets_pkey", name: "flow_buckets_pkey",
@@ -733,6 +750,7 @@ export type ServerInsert = typeof servers.$inferInsert
export type Snapshot = typeof serverSnapshots.$inferSelect export type Snapshot = typeof serverSnapshots.$inferSelect
export type SnapshotInsert = typeof serverSnapshots.$inferInsert export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect export type FilterRuleRow = typeof filterRules.$inferSelect
export type ConfigRevisionRow = typeof configRevisions.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
+1
View File
@@ -125,6 +125,7 @@ const TABLES: TableCopy[] = [
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"], ["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"], ["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
["flow_start_ms", "int"], ["flow_end_ms", "int"], ["flow_start_ms", "int"], ["flow_end_ms", "int"],
["nat_src", "inet"], ["nat_dst", "inet"], ["nat_src_port", "int"], ["nat_dst_port", "int"],
]}, ]},
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [ { table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"], ["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
+6
View File
@@ -29,7 +29,10 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js" import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js" import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js" import wireguardRoutes from "./routes/wireguard.js"
import vxlanRoutes from "./routes/vxlan.js"
import containersRoutes from "./routes/containers.js"
import firewallRoutes from "./routes/firewall.js" import firewallRoutes from "./routes/firewall.js"
import greRoutes from "./routes/gre.js"
import usersRoutes from "./routes/users.js" import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js" import statisticsRoutes from "./routes/statistics.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
@@ -134,7 +137,10 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" }) await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(vxlanRoutes, { prefix: "/api" })
await app.register(containersRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" }) await app.register(firewallRoutes, { prefix: "/api" })
await app.register(greRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" }) await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" }) await app.register(statisticsRoutes, { prefix: "/api" })
+8
View File
@@ -41,6 +41,14 @@ assert.equal(
permissionForRequest("GET", "/api/firewall/all"), permissionForRequest("GET", "/api/firewall/all"),
"mm:network:read", "mm:network:read",
) )
assert.equal(
permissionForRequest("GET", "/api/gre/tunnels"),
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/gre/tunnels"),
"mm:network:write",
)
assert.equal( assert.equal(
permissionForRequest("GET", "/api/users"), permissionForRequest("GET", "/api/users"),
"mm:users:read", "mm:users:read",
+4 -2
View File
@@ -155,7 +155,8 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") || p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") || p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"), p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:read", permission: "mm:network:read",
}, },
{ {
@@ -168,7 +169,8 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") || p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") || p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"), p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:write", permission: "mm:network:write",
}, },
] ]
+27 -1
View File
@@ -19,5 +19,31 @@ export function managedComment(label: string): string {
} }
export function managedRecursiveComment(comment?: string | null): string { export function managedRecursiveComment(comment?: string | null): string {
return comment ? `${PRODUCT_NAME}:recursive ${comment}` : `${PRODUCT_NAME}:recursive` const stripped = stripManagedRecursiveComment(comment ?? "")
return stripped ? `${PRODUCT_NAME}:recursive ${stripped}` : `${PRODUCT_NAME}:recursive`
}
const LEGACY_RECURSIVE_PREFIX = /^recursive:\s*/i
export function stripManagedRecursiveComment(comment: string): string {
const value = comment.trim()
if (!value) return ""
const managedPrefixes = [
`${PRODUCT_NAME}:recursive`,
`${LEGACY_PRODUCT_NAME}:recursive`,
]
for (const prefix of managedPrefixes) {
if (value.startsWith(prefix)) return value.slice(prefix.length).trim()
}
if (LEGACY_RECURSIVE_PREFIX.test(value)) {
return value.replace(LEGACY_RECURSIVE_PREFIX, "").trim()
}
return value
}
/** Owned recursive route: MM/legacy prefix or old `recursive:` mask. */
export function isOwnedRecursiveComment(comment: string | undefined): boolean {
if (!comment) return false
const value = comment.trim()
return hasManagedRecursiveComment(value) || LEGACY_RECURSIVE_PREFIX.test(value)
} }
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
getEnabledServerById,
listContainers,
listContainersForServer,
removeContainer,
restartContainer,
startContainer,
stopContainer,
} from "../services/containers-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const RosIdBodySchema = z.object({
rosId: z.string().min(1),
})
type RosIdBody = z.infer<typeof RosIdBodySchema>
type MutateFn = typeof startContainer
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/containers", async (_req, reply) => {
const containers = await listContainers()
return reply.send({ containers })
})
app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = await getEnabledServerById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const containers = await listContainersForServer(server)
return reply.send({ containers })
})
function registerMutate(path: string, fn: MutateFn) {
app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const body = req.body as RosIdBody
const server = await getEnabledServerById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
await fn(server, body.rosId)
return reply.send({ ok: true })
} catch (err) {
return reply.status(502).send({
error: err instanceof Error ? err.message : "Ошибка RouterOS",
})
}
})
}
registerMutate("/servers/:id/containers/start", startContainer)
registerMutate("/servers/:id/containers/stop", stopContainer)
registerMutate("/servers/:id/containers/restart", restartContainer)
registerMutate("/servers/:id/containers/remove", removeContainer)
}
export default containersRoutes
+204 -259
View File
@@ -1,14 +1,20 @@
import { and, asc, eq, inArray } from "drizzle-orm" import { and, asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js" import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
import { managedComment } from "../managed-markers.js"
import { planBgpInApply } from "../services/config-apply-plan.js"
import { import {
hasManagedCommentPrefix, appendRevisionIfChanged,
managedComment, canonicalFilterRules,
} from "../managed-markers.js" getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -296,47 +302,6 @@ async function resolveRouteTargets(serverId: number, rule: ApiFilterRule): Promi
return { gateway: rule.gateway, outIface: tid } return { gateway: rule.gateway, outIface: tid }
} }
function normalizeCommunity(c: string): string {
return (c ?? "").trim()
}
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
async function ruleEffectSignature(serverId: number, r: ApiFilterRule): Promise<string> {
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
const { gateway, outIface } = await resolveRouteTargets(serverId, r)
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
}
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
async function compareDbRulesWithRouter(
serverId: number,
dbRules: ApiFilterRule[],
remoteRules: ApiFilterRule[],
): Promise<Record<string, FilterRouterCompareStatus>> {
const remoteSigByComm = new Map<string, string>()
for (const rr of remoteRules) {
const c = normalizeCommunity(rr.community)
if (!remoteSigByComm.has(c)) {
remoteSigByComm.set(c, await ruleEffectSignature(serverId, rr))
}
}
const out: Record<string, FilterRouterCompareStatus> = {}
for (const dr of dbRules) {
const c = normalizeCommunity(dr.community)
const sigD = await ruleEffectSignature(serverId, dr)
const sigR = remoteSigByComm.get(c)
if (sigR === undefined) {
out[c] = "missing"
} else if (sigR !== sigD) {
out[c] = "drift"
} else {
out[c] = "synced"
}
}
return out
}
async function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): Promise<string> { async function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): Promise<string> {
if (rules.length === 0) return "" if (rules.length === 0) return ""
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же // Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
@@ -421,44 +386,80 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
) )
} }
const filtersRoutes: FastifyPluginAsyncZod = async (app) => { async function cacheRulesetsForServer(serverId: number): Promise<ApiFilterRule[]> {
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */ const rows = await db
app.get("/filters/router-compare", async (req, reply) => { .select()
const q = req.query as { serverId?: string | number } .from(filterRules)
const serverId = parseDbServerId(q.serverId) .where(eq(filterRules.serverId, serverId))
if (serverId === null) { .orderBy(asc(filterRules.sortOrder))
return reply.status(400).send({ error: "serverId is required" }) return rows.map((r) => ({
} id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
}
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] async function applyFiltersToServer(
if (!server) return reply.status(404).send({ error: "Server not found" }) server: ServerRow,
rules: ApiFilterRule[],
source: ConfigRevisionSource,
): Promise<{ pushed: number; action: string; conflictsRemoved: number }> {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const plan = planBgpInApply(existing, rules.length)
const managedCommentValue = managedComment(server.name || server.host)
try { if (plan.action === "patch" && plan.managedId) {
const remote = await fetchServerFilters(server) const ruleBody = await toRouterRuleBody(server.id, rules)
const rows = await db await client.patch(
.select() `/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
.from(filterRules) {
.where(eq(filterRules.serverId, serverId)) chain: "bgp-in",
.orderBy(asc(filterRules.sortOrder)) comment: managedCommentValue,
rule: ruleBody,
disabled: "no",
},
)
} else if (plan.action === "create") {
const ruleBody = await toRouterRuleBody(server.id, rules)
await client.post("/routing/filter/rule/add", {
chain: "bgp-in",
comment: managedCommentValue,
rule: ruleBody,
})
} else if (plan.action === "delete" && plan.managedId) {
await client.delete(
`/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
)
}
const dbRules: ApiFilterRule[] = rows.map(r => ({ for (const id of plan.conflictIds) {
id: String(r.id), await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
community: r.community, }
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
const byCommunity = await compareDbRulesWithRouter(serverId, dbRules, remote.rules) await replaceDbRules(server.id, rules)
return reply.send({ byCommunity }) await appendRevisionIfChanged({
} catch (err) { serverId: server.id,
app.log.error({ serverId, err: String(err) }, "filters router-compare failed") section: "filters",
return reply.status(500).send({ error: String(err) }) source,
} payload: canonicalFilterRules(rules),
}) })
return {
pushed: rules.length,
action: plan.action,
conflictsRemoved: plan.conflictIds.length,
}
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */ /** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
app.get("/filters/gre-tunnels", async (req, reply) => { app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
@@ -487,74 +488,103 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ tunnels: results.flat() }) return reply.send({ tunnels: results.flat() })
}) })
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */ /** Без serverId — cache для дашборда. С serverId — live с CHR, cache fallback. */
app.get("/filters/rules", async (_req, reply) => { app.get("/filters/rules", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
const allServers = await db.select().from(servers).where(eq(servers.enabled, true)) const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const dbRulesets = await toApiRulesets(allServers)
return reply.send({
rulesets: dbRulesets,
greTunnels: [] as LiveGreTunnel[],
})
})
app.put("/filters/rules", async (req, reply) => {
const body = req.body as { rulesets?: Array<{ serverId: string; rules: ApiFilterRule[] }> }
const payload = body.rulesets ?? []
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
if (serverIds.length > 0) {
await db.delete(filterRules).where(inArray(filterRules.serverId, serverIds))
}
for (const rs of payload) {
const sid = Number.parseInt(rs.serverId, 10)
if (!Number.isFinite(sid)) continue
await replaceDbRules(sid, rs.rules ?? [])
}
return reply.send({ ok: true })
})
app.post("/filters/sync/from-router", async (_req, reply) => {
const body = _req.body as { serverId?: string | number } | undefined
const rawServerId = body?.serverId
const serverId = parseDbServerId(rawServerId)
if (serverId === null) { if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" }) const dbRulesets = await toApiRulesets(allServers)
return reply.send({
rulesets: dbRulesets,
greTunnels: [] as LiveGreTunnel[],
live: false,
stale: false,
})
} }
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] const server = allServers.find((s) => s.id === serverId)
?? (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
await appendEvent({
level: "info",
eventType: "filters.sync.from_router.started",
sourceModule: "filters",
title: "Синхронизация фильтров запущена",
message: `${server.name || server.host} → БД`,
entityType: "server",
entityId: String(serverId),
})
const remote = await fetchServerFilters(server) const remote = await fetchServerFilters(server)
await replaceDbRules(server.id, remote.rules) await replaceDbRules(server.id, remote.rules)
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed") await appendRevisionIfChanged({
serverId: server.id,
section: "filters",
source: "observed",
payload: canonicalFilterRules(remote.rules),
})
const cached = await cacheRulesetsForServer(server.id)
return reply.send({
rulesets: [{ serverId: String(server.id), rules: cached }],
greTunnels: remote.tunnels,
live: true,
stale: false,
})
} catch (err) {
app.log.warn({ serverId, err: String(err) }, "filters live GET failed, serving cache")
const cached = await cacheRulesetsForServer(server.id)
return reply.send({
rulesets: [{ serverId: String(server.id), rules: cached }],
greTunnels: [] as LiveGreTunnel[],
live: false,
stale: true,
error: String(err),
})
}
})
app.put("/filters/rules", async (req, reply) => {
const body = req.body as {
serverId?: string | number
rules?: ApiFilterRule[]
source?: ConfigRevisionSource
}
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const rules = body.rules ?? []
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
try {
await appendEvent({ await appendEvent({
level: "info", level: "info",
eventType: "filters.sync.from_router.done", eventType: "filters.apply.started",
sourceModule: "filters", sourceModule: "filters",
title: "Синхронизация фильтров завершена", title: "Применение фильтров на роутер",
message: `${server.name || server.host}: ${remote.rules.length} правил`, message: `${server.name || server.host}: ${rules.length} правил`,
entityType: "server", entityType: "server",
entityId: String(serverId), entityId: String(serverId),
}) })
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId }) const result = await applyFiltersToServer(server, rules, source)
const cached = await cacheRulesetsForServer(server.id)
await appendEvent({
level: "info",
eventType: "filters.apply.done",
sourceModule: "filters",
title: "Фильтры применены",
message: `${server.name || server.host}: ${result.pushed} правил (${result.action})`,
entityType: "server",
entityId: String(serverId),
})
return reply.send({
ok: true,
serverId,
pushedRules: result.pushed,
action: result.action,
rules: cached,
})
} catch (err) { } catch (err) {
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed") app.log.error({ serverId, err: String(err) }, "filters apply failed")
await appendEvent({ await appendEvent({
level: "critical", level: "critical",
eventType: "filters.sync.from_router.failed", eventType: "filters.apply.failed",
sourceModule: "filters", sourceModule: "filters",
title: "Ошибка синхронизации фильтров", title: "Ошибка применения фильтров",
message: `${server.name || server.host}: ${String(err)}`, message: `${server.name || server.host}: ${String(err)}`,
entityType: "server", entityType: "server",
entityId: String(serverId), entityId: String(serverId),
@@ -563,145 +593,60 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
} }
}) })
app.post("/filters/sync/to-router", async (req, reply) => { app.get("/filters/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "filters")
return reply.send({ revisions })
})
app.post("/filters/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined const body = req.body as { serverId?: string | number } | undefined
const requestedServerId = parseDbServerId(body?.serverId) const rev = await getRevisionById(id)
if (!rev) return reply.status(404).send({ error: "Revision not found" })
const allServers = await db.select().from(servers).where(eq(servers.enabled, true)) if (rev.section !== "filters") return reply.status(400).send({ error: "Revision section mismatch" })
const targetServers = requestedServerId !== null const requested = parseDbServerId(body?.serverId)
? allServers.filter(s => s.id === requestedServerId) if (requested !== null && requested !== rev.serverId) {
: allServers return reply.status(400).send({ error: "Revision belongs to another server" })
if (requestedServerId !== null && targetServers.length === 0) {
return reply.status(404).send({ error: "Server not found" })
} }
const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
let updatedServers = 0 const raw = Array.isArray(rev.payload) ? rev.payload : []
let pushedRules = 0 const rules: ApiFilterRule[] = raw.map((item, idx) => {
const errors: Array<{ serverId: number; error: string }> = [] const r = item as Partial<ApiFilterRule>
await appendEvent({ return {
level: "info", id: `rev-${idx}`,
eventType: "filters.sync.to_router.started", community: r.community ?? "",
sourceModule: "filters", communityName: r.communityName,
title: "Отправка фильтров на роутеры запущена", action: r.action === "blackhole" ? "blackhole" : "route",
message: `Целевых серверов: ${targetServers.length}`, gateway: r.gateway ?? "",
payload: { requestedServerId }, gatewayTunnelId: r.gatewayTunnelId ?? "",
}) description: r.description ?? "",
for (const server of targetServers) {
try {
app.log.info({ serverId: server.id, host: server.host }, "Filters sync to router started")
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const isInBgpIn = (r: RosFilterRule) =>
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
const managedCommentValue = managedComment(server.name || server.host)
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
const managedRule = existing.find(
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
)
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
// удаляем после успешного upsert: иначе старое правило с `else { reject; }`
// отрабатывает первым и перебивает наш upsert.
const conflictIds = existing
.filter(r =>
isInBgpIn(r) &&
!hasManagedCommentPrefix(r.comment ?? "") &&
/bgp-communities/i.test(r.rule ?? ""),
)
.map(r => r[".id"])
.filter((id): id is string => Boolean(id))
const rows = await db.select().from(filterRules)
.where(and(eq(filterRules.serverId, server.id)))
.orderBy(asc(filterRules.sortOrder))
const rules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
// Upsert: PATCH существующего managed-правила или POST /add нового.
// Если ошибка — конфликтные правила НЕ удаляем (роутер не остаётся с пустым bgp-in).
// Путь `/routing/filter/rule/add` обязателен: голый POST на коллекцию RouterOS REST
// трактует как «вызов команды» и отдаёт 400 «no such command».
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
if (rules.length > 0) {
const ruleBody = await toRouterRuleBody(server.id, rules)
if (managedRule && managedRule[".id"]) {
await client.patch(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
{
chain: "bgp-in",
comment: managedCommentValue,
rule: ruleBody,
disabled: "no",
},
)
app.log.info({ serverId: server.id, id: managedRule[".id"] }, "bgp-in rule updated")
} else {
await client.post("/routing/filter/rule/add", {
chain: "bgp-in",
comment: managedCommentValue,
rule: ruleBody,
})
app.log.info({ serverId: server.id }, "bgp-in rule created")
}
pushedRules += rules.length
} else if (managedRule && managedRule[".id"]) {
// В БД нет правил → удаляем наш managed-rule на роутере.
await client.delete(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
)
app.log.info({ serverId: server.id }, "bgp-in rule removed (no rules in DB)")
}
for (const id of conflictIds) {
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
}
updatedServers += 1
app.log.info(
{
serverId: server.id,
mode: managedRule ? "patch" : "create",
conflictsRemoved: conflictIds.length,
pushed: rules.length,
},
"Filters sync to router completed",
)
} catch (err) {
app.log.error({ serverId: server.id, err: String(err) }, "filters sync to-router failed")
errors.push({ serverId: server.id, error: String(err) })
} }
} })
await appendEvent({ try {
level: errors.length === 0 ? "info" : "warning", const result = await applyFiltersToServer(server, rules, "rollback")
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial", const cached = await cacheRulesetsForServer(server.id)
sourceModule: "filters", await appendEvent({
title: errors.length === 0 ? "Отправка фильтров завершена" : "Отправка фильтров завершена с ошибками", level: "info",
message: `Успешно: ${updatedServers}, ошибок: ${errors.length}, правил: ${pushedRules}`, eventType: "filters.rollback.done",
payload: { sourceModule: "filters",
updatedServers, title: "Откат фильтров",
pushedRules, message: `${server.name || server.host}: ${result.pushed} правил`,
errors, entityType: "server",
}, entityId: String(server.id),
}) })
return reply.send({ return reply.send({ ok: true, rules: cached, pushedRules: result.pushed })
ok: errors.length === 0, } catch (err) {
updatedServers, app.log.error({ serverId: server.id, err: String(err) }, "filters restore failed")
pushedRules, return reply.status(500).send({ error: String(err) })
errors, }
})
}) })
} }
+73 -1
View File
@@ -2,7 +2,20 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod" import { z } from "zod"
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js" import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js" import { getEnabledServerById } from "../services/wireguard-live.js"
import { listFirewallAll } from "../services/firewall-live.js" import {
captureFirewallSnapshot,
fetchFirewallState,
listFirewallAll,
} from "../services/firewall-live.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseFirewallSnapshot, planFirewallRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import type { FirewallFamily, FirewallTable } from "../types/server.js" import type { FirewallFamily, FirewallTable } from "../types/server.js"
const FamilySchema = z.enum(["ip", "ip6"]) const FamilySchema = z.enum(["ip", "ip6"])
@@ -120,6 +133,20 @@ async function requireServer(serverId: string) {
return await getEnabledServerById(serverId) return await getEnabledServerById(serverId)
} }
async function recordFirewall(
server: NonNullable<Awaited<ReturnType<typeof requireServer>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "firewall",
source,
capture: () => captureFirewallSnapshot(server),
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const firewallRoutes: FastifyPluginAsyncZod = async (app) => { const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/firewall/all", async (_req, reply) => { app.get("/firewall/all", async (_req, reply) => {
const data = await listFirewallAll() const data = await listFirewallAll()
@@ -138,6 +165,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable) const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
try { try {
await client.put(path, ruleToRos(body)) await client.put(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true }) return reply.status(201).send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -156,6 +184,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, ruleToRos(body)) await client.patch(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -174,6 +203,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" }) await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -192,6 +222,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try { try {
await client.delete(path) await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -213,6 +244,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
numbers: body.rosId, numbers: body.rosId,
...(body.destinationRosId ? { destination: body.destinationRosId } : {}), ...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
}) })
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -230,6 +262,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body)) await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true }) return reply.status(201).send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -248,6 +281,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, addressToRos(body)) await client.patch(path, addressToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -266,6 +300,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try { try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" }) await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -284,11 +319,48 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try { try {
await client.delete(path) await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
} }
}) })
app.get("/firewall/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "firewall")
return reply.send({ revisions })
})
app.post("/firewall/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "firewall",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
const client = MikrotikClient.fromServer(loaded.server)
try {
const desired = parseFirewallSnapshot(loaded.row.payload)
const state = await fetchFirewallState(loaded.server)
const ops = planFirewallRestore(desired, {
rules: state.liveRules,
addressLists: state.liveLists,
})
await executeRosOps(client, ops)
await recordFirewall(loaded.server, "rollback")
const next = await fetchFirewallState(loaded.server)
return reply.send({ ok: true, rules: next.rules, addressLists: next.addressLists })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
} }
export default firewallRoutes export default firewallRoutes
+229
View File
@@ -0,0 +1,229 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import {
captureGreSnapshot,
fetchGreState,
formatKeepalive,
listGreTunnels,
parseKeepalive,
} from "../services/gre-live.js"
import {
canonicalGreSnapshot,
parseGreSnapshot,
planGreCreate,
planGreDelete,
planGreRestore,
} from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseDbServerId } from "../utils/server-id.js"
const TunnelWriteSchema = z.object({
serverId: z.string().min(1),
name: z.string().min(1),
rosId: z.string().optional(),
localAddress: z.string().optional(),
remoteAddress: z.string().min(1),
localInnerIp: z.string().optional(),
remoteInnerIp: z.string().optional(),
comment: z.string().optional(),
enabled: z.boolean().optional(),
mtu: z.number().optional(),
keepaliveInterval: z.number().optional(),
keepaliveRetries: z.number().optional(),
dscp: z.union([z.literal("inherit"), z.number(), z.string()]).optional(),
clampTcpMss: z.boolean().optional(),
allowFastPath: z.boolean().optional(),
ipsecSecret: z.string().optional(),
})
const TunnelKeySchema = z.object({
serverId: z.string().min(1),
rosId: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
})
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
function rosErr(e: unknown): string {
if (e instanceof MikrotikError) return e.message
if (e instanceof Error) return e.message
return String(e)
}
async function recordGre(
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "gre",
source,
capture: () => captureGreSnapshot(server),
})
}
function tunnelFromBody(body: z.infer<typeof TunnelWriteSchema> & { keepaliveInterval?: number; keepaliveRetries?: number }) {
const dscp = body.dscp == null
? "inherit"
: typeof body.dscp === "number"
? String(body.dscp)
: body.dscp
const keepalive = body.keepaliveInterval === undefined && body.keepaliveRetries === undefined
? undefined
: formatKeepalive(body.keepaliveInterval ?? 0, body.keepaliveRetries ?? 10)
return canonicalGreSnapshot({
tunnels: [{
name: body.name,
localAddress: body.localAddress ?? "",
remoteAddress: body.remoteAddress,
localInnerIp: body.localInnerIp ?? "",
remoteInnerIp: body.remoteInnerIp ?? "",
comment: body.comment ?? "",
disabled: body.enabled === false,
mtu: body.mtu ?? 1476,
keepalive: keepalive ?? "0",
dscp,
clampTcpMss: body.clampTcpMss,
allowFastPath: body.allowFastPath,
ipsecSecret: body.ipsecSecret ?? "",
}],
}).tunnels[0]!
}
const greRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/gre/tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseDbServerId(q.serverId)
const result = await listGreTunnels({ serverId: sid !== null ? String(sid) : undefined })
return reply.send(result)
})
app.post("/gre/tunnels", async (req, reply) => {
const parsed = TunnelWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const tunnel = tunnelFromBody(body)
await executeRosOps(client, planGreCreate(tunnel))
await recordGre(server, "apply")
const state = await fetchGreState(server)
const created = state.tunnels.find((t) => t.name === tunnel.name)
return reply.status(201).send(created ?? { ok: true, name: tunnel.name })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/gre/tunnels", async (req, reply) => {
const parsed = TunnelWriteSchema.partial().required({ serverId: true }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
try {
const state = await fetchGreState(server)
const live = state.gre.find((g) =>
(body.rosId && g.rosId === body.rosId) || (body.name && g.name === body.name),
)
if (!live) return reply.status(404).send({ error: "Туннель не найден" })
const merged = tunnelFromBody({
serverId: body.serverId,
name: body.name || live.name,
localAddress: body.localAddress ?? live.localAddress,
remoteAddress: body.remoteAddress || live.remoteAddress,
localInnerIp: body.localInnerIp ?? state.addrs.find((a) => a.interfaceName === live.name)?.address ?? "",
remoteInnerIp: body.remoteInnerIp,
comment: body.comment ?? live.comment,
enabled: body.enabled ?? !live.disabled,
mtu: body.mtu ?? live.mtu,
keepaliveInterval: body.keepaliveInterval ?? parseKeepalive(live.keepalive).interval,
keepaliveRetries: body.keepaliveRetries ?? parseKeepalive(live.keepalive).retries,
dscp: body.dscp ?? live.dscp,
clampTcpMss: body.clampTcpMss ?? live.clampTcpMss,
allowFastPath: body.allowFastPath ?? live.allowFastPath,
ipsecSecret: body.ipsecSecret ?? live.ipsecSecret,
})
const ops = planGreRestore(
{ tunnels: state.snapshot.tunnels.map((t) => t.name === live.name ? merged : t) },
{ gre: state.gre, addrs: state.addrs },
)
await executeRosOps(state.client, ops)
await recordGre(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/gre/tunnels", async (req, reply) => {
const parsed = TunnelKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
try {
const state = await fetchGreState(server)
const live = state.gre.find((g) =>
(body.rosId && g.rosId === body.rosId) || (body.name && g.name === body.name),
)
if (!live) return reply.status(404).send({ error: "Туннель не найден" })
await executeRosOps(state.client, planGreDelete(live.name, { gre: state.gre, addrs: state.addrs }))
await recordGre(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.get("/gre/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "gre")
return reply.send({ revisions })
})
app.post("/gre/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "gre",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseGreSnapshot(loaded.row.payload)
const state = await fetchGreState(loaded.server)
const ops = planGreRestore(desired, { gre: state.gre, addrs: state.addrs })
await executeRosOps(state.client, ops)
await recordGre(loaded.server, "rollback")
const next = await fetchGreState(loaded.server)
return reply.send({ ok: true, tunnels: next.tunnels })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
}
export default greRoutes
+35 -7
View File
@@ -6,9 +6,10 @@ import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type { import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession, RosBfdSession, RosIpRoute,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead, OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, OspfRouteRead, BfdSessionRead,
} from "../types/server.js" } from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod" import { z } from "zod"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -74,14 +75,15 @@ function parseAddrIface(addr: string): { ip: string; iface: string } {
/** Fetch all OSPF + BFD data for one server */ /** Fetch all OSPF + BFD data for one server */
async function fetchServerOspf(server: ServerRow) { async function fetchServerOspf(server: ServerRow) {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const [neighbors, areas, ifaceTemplates, instances, bfdSessions] = await Promise.all([ const [neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes] = await Promise.all([
client.getOspfNeighbors(), client.getOspfNeighbors(),
client.getOspfAreas(), client.getOspfAreas(),
client.getOspfInterfaceTemplates(), client.getOspfInterfaceTemplates(),
client.getOspfInstances(), client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
client.getIpRoutes().catch(() => [] as RosIpRoute[]),
]) ])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions } return { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes }
} }
// ── BFD parser ──────────────────────────────────────────────────────────────── // ── BFD parser ────────────────────────────────────────────────────────────────
@@ -200,6 +202,29 @@ function parseInstances(
})) }))
} }
function parseOspfRoutes(server: ServerRow, routes: RosIpRoute[]): OspfRouteRead[] {
const out: OspfRouteRead[] = []
for (const [idx, r] of routes.entries()) {
const type = parseOspfRouteType(r)
if (!type) continue
const { nextHop, via } = parseOspfGateway(r)
const metric = parseInt(r["ospf-metric"] ?? r.distance ?? "0") || 0
out.push({
id: r[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
destination: r["dst-address"] ?? "",
type,
cost: metric,
nextHop,
via,
area: r["ospf-area"] ?? "",
})
}
return out
}
function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) { function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6) const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18) const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
@@ -584,16 +609,17 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
const perServer = await Promise.all( const perServer = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
try { try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server) const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas) const areaMap = buildAreaMap(areas)
return { return {
neighbors: parseNeighbors(server, neighbors, areaMap), neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances), instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions), bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
} }
} catch { } catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] } return { neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }
} }
}), }),
) )
@@ -603,6 +629,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
interfaces: perServer.flatMap(r => r.interfaces), interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances), instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions), bfdSessions: perServer.flatMap(r => r.bfdSessions),
routes: perServer.flatMap(r => r.routes),
}) })
}) })
@@ -634,13 +661,14 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
try { try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server) const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas) const areaMap = buildAreaMap(areas)
return reply.send({ return reply.send({
neighbors: parseNeighbors(server, neighbors, areaMap), neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances), instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions), bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })), areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
}) })
} catch (err) { } catch (err) {
+141 -80
View File
@@ -1,13 +1,23 @@
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js" import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { managedRecursiveComment } from "../managed-markers.js"
import { import {
hasManagedRecursiveComment, mapRosManagedRoutes,
managedRecursiveComment, planRecursiveApply,
} from "../managed-markers.js" userRecursiveComment,
} from "../services/config-apply-plan.js"
import {
appendRevisionIfChanged,
canonicalRecursiveRoutes,
getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -51,27 +61,6 @@ interface RecursiveRouteDto {
disabled: boolean disabled: boolean
} }
function isIpGateway(gw: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
}
function isRecursiveRoute(r: RosRoute): boolean {
if ((r.static ?? "false") !== "true") return false
if ((r.dynamic ?? "false") === "true") return false
if ((r.blackhole ?? "false") === "true") return false
if ((r.unreachable ?? "false") === "true") return false
if ((r.prohibit ?? "false") === "true") return false
const dst = r["dst-address"] ?? ""
const gw = r.gateway ?? ""
if (!dst || !gw) return false
return isIpGateway(gw)
}
function hasRecursiveCommentMask(comment: string | undefined): boolean {
if (!comment) return false
return /^recursive:\s*/i.test(comment.trim())
}
function splitGateway(raw: string): { ip: string; name: string } | null { function splitGateway(raw: string): { ip: string; name: string } | null {
const v = raw.trim() const v = raw.trim()
if (!v) return null if (!v) return null
@@ -102,6 +91,21 @@ async function mapDbRoutes(serverId: number): Promise<RecursiveRouteDto[]> {
})) }))
} }
function mergeCachedCountry(
live: RecursiveRouteDto[],
cached: RecursiveRouteDto[],
): RecursiveRouteDto[] {
return live.map((row) => {
if (row.country) return row
const match = cached.find((c) =>
c.dstAddress === row.dstAddress &&
c.gateway === row.gateway &&
c.distance === row.distance,
)
return match?.country ? { ...row, country: match.country } : row
})
}
function toRouterPayload(route: RecursiveRouteDto): Record<string, string> { function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
return { return {
"dst-address": route.dstAddress, "dst-address": route.dstAddress,
@@ -132,7 +136,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
routingTable: r.routingTable || "main", routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "", checkGateway: r.checkGateway ?? "",
country: r.country ?? "", country: r.country ?? "",
comment: r.comment ?? "", comment: userRecursiveComment(r.comment),
disabled: r.disabled, disabled: r.disabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -140,6 +144,34 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
) )
} }
async function applyRecursiveToServer(
server: ServerRow,
routes: RecursiveRouteDto[],
source: ConfigRevisionSource,
): Promise<{ pushed: number; deleted: number }> {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const { deleteIds } = planRecursiveApply(existing)
for (const route of routes) {
await client.post("/ip/route", toRouterPayload(route))
}
for (const id of deleteIds) {
await client.delete(`/ip/route/${encodeURIComponent(id)}`)
}
await replaceDbRoutes(server.id, routes)
await appendRevisionIfChanged({
serverId: server.id,
section: "recursive-routes",
source,
payload: canonicalRecursiveRoutes(routes),
})
return { pushed: routes.length, deleted: deleteIds.length }
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.get("/recursive-routes/gateways", async (req, reply) => { app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
@@ -175,79 +207,108 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId) const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send({ routes: await mapDbRoutes(serverId) })
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
await replaceDbRoutes(serverId, body.routes ?? [])
return reply.send({ ok: true })
})
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseDbServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = (await db const server: ServerRow | undefined = (await db
.select().from(servers) .select().from(servers)
.where(eq(servers.id, serverId)) .where(eq(servers.id, serverId))
.limit(1))[0] .limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const cached = await mapDbRoutes(serverId)
try { try {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route") const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const rec = rosRoutes.filter(r => const live = mergeCachedCountry(mapRosManagedRoutes(rosRoutes), cached)
isRecursiveRoute(r) && hasRecursiveCommentMask(r.comment), await replaceDbRoutes(serverId, live)
) await appendRevisionIfChanged({
const mapped: RecursiveRouteDto[] = rec.map((r, i) => ({ serverId,
id: r[".id"] ?? `ros-${i}`, section: "recursive-routes",
dstAddress: r["dst-address"] ?? "", source: "observed",
gateway: r.gateway ?? "", payload: canonicalRecursiveRoutes(live),
distance: Number.parseInt(r.distance ?? "1", 10) || 1, })
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null, const stored = await mapDbRoutes(serverId)
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null, return reply.send({ routes: stored, live: true, stale: false })
routingTable: r["routing-table"] ?? "main", } catch (err) {
checkGateway: r["check-gateway"] ?? "", app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache")
country: "", return reply.send({
comment: r.comment ?? "", routes: cached,
disabled: r.disabled === "true", live: false,
})) stale: true,
await replaceDbRoutes(serverId, mapped) error: String(err),
return reply.send({ ok: true, serverId, totalRoutes: mapped.length }) })
}
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as {
serverId?: string | number
routes?: RecursiveRouteDto[]
source?: ConfigRevisionSource
}
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const routes = (body.routes ?? []).map((r) => ({
...r,
comment: userRecursiveComment(r.comment),
}))
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
try {
const result = await applyRecursiveToServer(server, routes, source)
const stored = await mapDbRoutes(serverId)
return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
} catch (err) { } catch (err) {
return reply.status(500).send({ error: String(err) }) return reply.status(500).send({ error: String(err) })
} }
}) })
app.post("/recursive-routes/sync/to-router", async (req, reply) => { app.get("/recursive-routes/revisions", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(body?.serverId) const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] const revisions = await listRevisions(serverId, "recursive-routes")
return reply.send({ revisions })
})
app.post("/recursive-routes/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const rev = await getRevisionById(id)
if (!rev) return reply.status(404).send({ error: "Revision not found" })
if (rev.section !== "recursive-routes") {
return reply.status(400).send({ error: "Revision section mismatch" })
}
const requested = parseDbServerId(body?.serverId)
if (requested !== null && requested !== rev.serverId) {
return reply.status(400).send({ error: "Revision belongs to another server" })
}
const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const raw = Array.isArray(rev.payload) ? rev.payload : []
const routes: RecursiveRouteDto[] = raw.map((item, idx) => {
const r = item as Partial<RecursiveRouteDto>
return {
id: `rev-${idx}`,
dstAddress: r.dstAddress ?? "",
gateway: r.gateway ?? "",
distance: r.distance ?? 1,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: userRecursiveComment(r.comment),
disabled: Boolean(r.disabled),
}
})
try { try {
const client = MikrotikClient.fromServer(server) const result = await applyRecursiveToServer(server, routes, "rollback")
const existing = await client.get<RosRoute[]>("/ip/route") const stored = await mapDbRoutes(server.id)
const managed = existing.filter(r => hasManagedRecursiveComment(r.comment ?? "")) return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
}
const dbRows = await mapDbRoutes(serverId)
for (const route of dbRows) {
await client.post("/ip/route", toRouterPayload(route))
}
return reply.send({ ok: true, serverId, pushedRoutes: dbRows.length })
} catch (err) { } catch (err) {
return reply.status(500).send({ error: String(err) }) return reply.status(500).send({ error: String(err) })
} }
+10 -1
View File
@@ -2,6 +2,9 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { count } from "drizzle-orm" import { count } from "drizzle-orm"
import { listCertificatesFromServers } from "../services/certificates-service.js" import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js" import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { countVxlanTunnels } from "../services/vxlan-live.js"
import { countContainers } from "../services/containers-live.js"
import { countBgpSessions } from "../services/bgp-peers-live.js"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { import {
filterRules, filterRules,
@@ -24,9 +27,12 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
const uptimeProbesTotal = await tableCount(uptimeProbes) const uptimeProbesTotal = await tableCount(uptimeProbes)
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes) const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
const recursiveRoutesTotal = await tableCount(recursiveRoutes) const recursiveRoutesTotal = await tableCount(recursiveRoutes)
const [certRes, wireguardTotal] = await Promise.all([ const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
listCertificatesFromServers(), listCertificatesFromServers(),
countWireGuardInterfaces().catch(() => 0), countWireGuardInterfaces().catch(() => 0),
countBgpSessions().catch(() => 0),
countVxlanTunnels().catch(() => 0),
countContainers().catch(() => 0),
]) ])
const certificatesTotal = certRes.certificates.length const certificatesTotal = certRes.certificates.length
const usersTotal = (await listUsers()).length const usersTotal = (await listUsers()).length
@@ -41,6 +47,9 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
certificates: certificatesTotal, certificates: certificatesTotal,
wireguard: wireguardTotal, wireguard: wireguardTotal,
users: usersTotal, users: usersTotal,
bgpSessions: bgpTotal,
vxlan: vxlanTotal,
containers: containersTotal,
}) })
}) })
} }
+22
View File
@@ -27,6 +27,7 @@ import {
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js" import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js" import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js" import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
import { rebuildFlowFactsFromBuckets } from "../services/traffic-flow-facts-rebuild.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
const LIVE_TICK_MS = 2000 const LIVE_TICK_MS = 2000
@@ -203,6 +204,27 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
} }
}) })
app.post("/traffic/flow/rebuild-facts", async (_req, reply) => {
try {
const result = await rebuildFlowFactsFromBuckets()
await appendEvent({
level: "info",
eventType: "traffic.flow.rebuild_facts",
sourceModule: "traffic",
title: "Пересчитан куб NetFlow",
message: `Факты ${result.facts} из ${result.buckets} сессий, дней ${result.days.length}`,
entityType: "traffic_flow",
entityId: "rebuild-facts",
payload: { buckets: result.buckets, facts: result.facts, days: result.days },
})
return reply.send(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 500
return reply.status(status).send({ error: message })
}
})
app.post("/traffic/flow/overlay", applyOverlayHandler) app.post("/traffic/flow/overlay", applyOverlayHandler)
app.post("/traffic/flow-overlay", applyOverlayHandler) app.post("/traffic/flow-overlay", applyOverlayHandler)
+23
View File
@@ -0,0 +1,23 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listVxlanTunnels, listVxlanTunnelsForServer } from "../services/vxlan-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const vxlanRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/vxlan", async (_req, reply) => {
const tunnels = await listVxlanTunnels()
return reply.send({ tunnels })
})
app.get("/servers/:id/vxlan", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const tunnels = await listVxlanTunnelsForServer(server)
return reply.send({ tunnels })
})
}
export default vxlanRoutes
+78
View File
@@ -18,6 +18,8 @@ import {
type WgParsedConfig, type WgParsedConfig,
} from "../services/wireguard-config.js" } from "../services/wireguard-config.js"
import { import {
captureWireguardSnapshot,
fetchWireguardRestoreState,
getEnabledServerById, getEnabledServerById,
listWireGuardInterfaces, listWireGuardInterfaces,
} from "../services/wireguard-live.js" } from "../services/wireguard-live.js"
@@ -27,6 +29,16 @@ import {
putWireguardPeer, putWireguardPeer,
toRosBody, toRosBody,
} from "../services/wireguard-ros.js" } from "../services/wireguard-ros.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseWireguardSnapshot, planWireguardRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import { z } from "zod"
function serverIdParam(v: string): string { function serverIdParam(v: string): string {
return decodeURIComponent(v) return decodeURIComponent(v)
@@ -137,6 +149,20 @@ function findIface(
return list.find((i) => i.serverId === serverId && i.name === interfaceName) return list.find((i) => i.serverId === serverId && i.name === interfaceName)
} }
async function recordWireguard(
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "wireguard",
source,
capture: () => captureWireguardSnapshot(server),
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => { const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/wireguard", async (req, reply) => { app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string } const q = req.query as { serverId?: string; includePrivateKey?: string }
@@ -145,6 +171,11 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
serverId: q.serverId, serverId: q.serverId,
includePrivateKey, includePrivateKey,
}) })
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledServerById(sid)
if (server) await recordWireguard(server, "observed")
}
return reply.send(result) return reply.send(result)
}) })
@@ -181,6 +212,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
includePrivateKey: true, includePrivateKey: true,
}) })
const created = list.interfaces.find((i) => i.name === body.name) const created = list.interfaces.find((i) => i.name === body.name)
await recordWireguard(server, "apply")
return reply.status(201).send(created ?? { ok: true, name: body.name }) return reply.status(201).send(created ?? { ok: true, name: body.name })
} catch (e) { } catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e) const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
@@ -210,6 +242,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}), }),
) )
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -224,6 +257,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`) await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -242,6 +276,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await putWireguardPeer(client, peerToRosBody(body)) await putWireguardPeer(client, peerToRosBody(body))
await recordWireguard(server, "apply")
return reply.status(201).send({ ok: true }) return reply.status(201).send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -277,6 +312,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}), }),
) )
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -291,6 +327,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`) await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -320,6 +357,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
const applied = await applyParsedConfig(client, config) const applied = await applyParsedConfig(client, config)
await recordWireguard(server, "copy")
return reply.send({ dryRun: false, preview, applied }) return reply.send({ dryRun: false, preview, applied })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -437,6 +475,46 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
content, content,
}) })
}) })
app.get("/wireguard/revisions", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "wireguard")
return reply.send({ revisions })
})
app.post("/wireguard/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "wireguard",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseWireguardSnapshot(loaded.row.payload)
const state = await fetchWireguardRestoreState(loaded.server)
const ops = planWireguardRestore(desired, {
ifaces: state.ifaces,
peers: state.peers,
addrs: state.addrs,
})
await executeRosOps(state.client, ops)
await recordWireguard(loaded.server, "rollback")
const list = await listWireGuardInterfaces({
serverId: String(loaded.server.id),
includePrivateKey: true,
})
return reply.send({ ok: true, interfaces: list.interfaces })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
} }
export default wireguardRoutes export default wireguardRoutes
@@ -0,0 +1,8 @@
import { initDatabase } from "../db/bootstrap.js"
import { closePool } from "../db/index.js"
import { rebuildFlowFactsFromBuckets } from "../services/traffic-flow-facts-rebuild.js"
await initDatabase()
const result = await rebuildFlowFactsFromBuckets()
console.log(JSON.stringify(result, null, 2))
await closePool()
+13
View File
@@ -28,3 +28,16 @@ export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
) )
return results.flat() return results.flat()
} }
export async function countBgpSessions(): Promise<number> {
try {
const result = await Promise.race([
fetchBgpSessionsForAlerts(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.length
} catch {
return 0
}
}
@@ -0,0 +1,96 @@
import assert from "node:assert/strict"
import { hasManagedCommentPrefix, isOwnedRecursiveComment, managedRecursiveComment, stripManagedRecursiveComment } from "../managed-markers.js"
import {
planBgpInApply,
planRecursiveApply,
unmanagedRouteIds,
} from "./config-apply-plan.js"
import {
canonicalFilterRules,
fingerprintPayload,
} from "./config-revisions.js"
import { mapRosManagedRoutes } from "./config-apply-plan.js"
{
const fp1 = fingerprintPayload(canonicalFilterRules([
{ community: "65001:100", action: "route", gateway: "10.0.0.1", gatewayTunnelId: "gre1", description: "a" },
]))
const fp2 = fingerprintPayload(canonicalFilterRules([
{ community: "65001:100", action: "route", gateway: "10.0.0.1", gatewayTunnelId: "gre1", description: "a" },
]))
const fp3 = fingerprintPayload(canonicalFilterRules([
{ community: "65001:100", action: "route", gateway: "10.0.0.2", gatewayTunnelId: "gre1", description: "a" },
]))
assert.equal(fp1, fp2)
assert.notEqual(fp1, fp3)
}
{
const existing = [
{ ".id": "*1", chain: "bgp-in", comment: "MikrotikManager: msk", rule: "if (true) { accept; }" },
{ ".id": "*2", chain: "bgp-in", comment: "legacy", rule: "if (bgp-communities includes 1:1) { reject; }" },
{ ".id": "*3", chain: "bgp-out", comment: "MikrotikManager: other", rule: "if (bgp-communities includes 1:1) { accept; }" },
]
const patch = planBgpInApply(existing, 3)
assert.equal(patch.action, "patch")
assert.equal(patch.managedId, "*1")
assert.deepEqual(patch.conflictIds, ["*2"])
const create = planBgpInApply(existing.filter((r) => r[".id"] !== "*1"), 1)
assert.equal(create.action, "create")
assert.equal(create.managedId, undefined)
const del = planBgpInApply(existing, 0)
assert.equal(del.action, "delete")
assert.equal(del.managedId, "*1")
const noop = planBgpInApply([], 0)
assert.equal(noop.action, "noop")
}
{
const routes = [
{ ".id": "*10", comment: "MikrotikManager:recursive via de", static: "true", "dst-address": "8.8.8.8/32", gateway: "1.1.1.1" },
{ ".id": "*11", comment: "user static", static: "true", "dst-address": "1.1.1.1/32", gateway: "9.9.9.9" },
{ ".id": "*12", comment: "recursive: old", static: "true", "dst-address": "9.9.9.9/32", gateway: "1.1.1.1" },
]
const plan = planRecursiveApply(routes)
assert.deepEqual(plan.deleteIds, ["*10", "*12"])
assert.deepEqual(unmanagedRouteIds(routes), ["*11"])
}
{
assert.equal(stripManagedRecursiveComment("MikrotikManager:recursive via de"), "via de")
assert.equal(stripManagedRecursiveComment("recursive: old"), "old")
assert.equal(managedRecursiveComment("MikrotikManager:recursive via de"), "MikrotikManager:recursive via de")
assert.equal(isOwnedRecursiveComment("MikrotikManager:recursive via de"), true)
assert.equal(isOwnedRecursiveComment("recursive: x"), true)
assert.equal(isOwnedRecursiveComment("user static"), false)
assert.equal(hasManagedCommentPrefix("MikrotikManager: msk"), true)
}
{
const mapped = mapRosManagedRoutes([
{
".id": "*1",
static: "true",
"dst-address": "10.9.9.2/32",
gateway: "1.2.3.4",
comment: "MikrotikManager:recursive hop-de",
distance: "1",
},
{
".id": "*2",
static: "true",
"dst-address": "10.9.9.3/32",
gateway: "1.2.3.4",
comment: "not ours",
distance: "1",
},
])
assert.equal(mapped.length, 1)
assert.equal(mapped[0]?.dstAddress, "10.9.9.2/32")
assert.equal(mapped[0]?.comment, "hop-de")
}
console.log("config-apply-plan.test.ts: ok")
+140
View File
@@ -0,0 +1,140 @@
import {
hasManagedCommentPrefix,
isOwnedRecursiveComment,
stripManagedRecursiveComment,
} from "../managed-markers.js"
export type RosFilterRuleLike = {
".id"?: string
chain?: string
rule?: string
comment?: string
}
export type BgpInApplyAction = "patch" | "create" | "delete" | "noop"
export interface BgpInApplyPlan {
action: BgpInApplyAction
managedId?: string
conflictIds: string[]
}
function isInBgpIn(rule: RosFilterRuleLike): boolean {
return (rule.chain ?? "").trim().toLowerCase() === "bgp-in"
}
export function planBgpInApply(
existing: RosFilterRuleLike[],
rulesCount: number,
): BgpInApplyPlan {
const managed = existing.find(
(r) => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
)
const conflictIds = existing
.filter((r) =>
isInBgpIn(r) &&
!hasManagedCommentPrefix(r.comment ?? "") &&
/bgp-communities/i.test(r.rule ?? ""),
)
.map((r) => r[".id"])
.filter((id): id is string => Boolean(id))
if (rulesCount > 0) {
return {
action: managed?.[".id"] ? "patch" : "create",
managedId: managed?.[".id"],
conflictIds,
}
}
if (managed?.[".id"]) {
return { action: "delete", managedId: managed[".id"], conflictIds }
}
return { action: "noop", conflictIds }
}
export type RosRouteLike = {
".id"?: string
comment?: string
static?: string
dynamic?: string
blackhole?: string
unreachable?: string
prohibit?: string
"dst-address"?: string
gateway?: string
}
export function planRecursiveApply(existing: RosRouteLike[]): { deleteIds: string[] } {
return {
deleteIds: existing
.filter((r) => isOwnedRecursiveComment(r.comment))
.map((r) => r[".id"])
.filter((id): id is string => Boolean(id)),
}
}
export function unmanagedRouteIds(existing: RosRouteLike[]): string[] {
return existing
.filter((r) => Boolean(r[".id"]) && !isOwnedRecursiveComment(r.comment))
.map((r) => r[".id"] as string)
}
export function userRecursiveComment(comment: string | undefined): string {
return stripManagedRecursiveComment(comment ?? "")
}
function isIpGateway(gw: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
}
export function isManagedRecursiveRoute(r: RosRouteLike): boolean {
if ((r.static ?? "false") !== "true") return false
if ((r.dynamic ?? "false") === "true") return false
if ((r.blackhole ?? "false") === "true") return false
if ((r.unreachable ?? "false") === "true") return false
if ((r.prohibit ?? "false") === "true") return false
const dst = r["dst-address"] ?? ""
const gw = r.gateway ?? ""
if (!dst || !gw) return false
if (!isIpGateway(gw)) return false
return isOwnedRecursiveComment(r.comment)
}
export interface MappedRecursiveRoute {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
country: string
comment: string
disabled: boolean
}
export function mapRosManagedRoutes(
rosRoutes: Array<RosRouteLike & {
distance?: string
scope?: string
"target-scope"?: string
"routing-table"?: string
"check-gateway"?: string
disabled?: string
}>,
): MappedRecursiveRoute[] {
return rosRoutes.filter(isManagedRecursiveRoute).map((r, i) => ({
id: r[".id"] ?? `ros-${i}`,
dstAddress: r["dst-address"] ?? "",
gateway: r.gateway ?? "",
distance: Number.parseInt(r.distance ?? "1", 10) || 1,
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null,
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null,
routingTable: r["routing-table"] ?? "main",
checkGateway: r["check-gateway"] ?? "",
country: "",
comment: userRecursiveComment(r.comment),
disabled: r.disabled === "true",
}))
}
@@ -0,0 +1,101 @@
import assert from "node:assert/strict"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js"
import {
appendRevisionIfChanged,
fingerprintPayload,
getRevisionById,
listRevisions,
pruneRevisions,
} from "./config-revisions.js"
if (!(await withPgOrSkip())) {
console.log("config-revisions.test.ts: skip")
process.exit(0)
}
const tag = `rev-test-${Date.now()}`
await dbQuery(`INSERT INTO servers (name, host) VALUES ($1, '127.0.0.1')`, [tag])
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = $1 LIMIT 1`, [tag])
const serverId = rows[0]?.id
assert.ok(serverId)
try {
const payloadA = [{ community: "1:1", action: "route" }]
const first = await appendRevisionIfChanged({
serverId,
section: "filters",
source: "apply",
payload: payloadA,
})
assert.equal(first.created, true)
assert.equal(first.revision.source, "apply")
const dup = await appendRevisionIfChanged({
serverId,
section: "filters",
source: "observed",
payload: payloadA,
})
assert.equal(dup.created, false)
assert.equal(dup.revision.id, first.revision.id)
const payloadB = [{ community: "1:2", action: "blackhole" }]
const second = await appendRevisionIfChanged({
serverId,
section: "filters",
source: "rollback",
payload: payloadB,
})
assert.equal(second.created, true)
assert.equal(second.revision.source, "rollback")
assert.notEqual(second.revision.fingerprint, first.revision.fingerprint)
const listed = await listRevisions(serverId, "filters")
assert.equal(listed.length, 2)
assert.equal(listed[0]?.source, "rollback")
const stored = await getRevisionById(second.revision.id)
assert.ok(stored)
assert.equal(fingerprintPayload(stored.payload), second.revision.fingerprint)
const objPayload = { rules: [{ chain: "input" }], addressLists: [{ list: "vip" }] }
const objRev = await appendRevisionIfChanged({
serverId,
section: "firewall",
source: "apply",
payload: objPayload,
})
assert.equal(objRev.created, true)
assert.equal(objRev.revision.itemCount, 2)
const objStored = await getRevisionById(objRev.revision.id)
assert.ok(objStored)
assert.ok(!Array.isArray(objStored.payload))
assert.equal(fingerprintPayload(objStored.payload), objRev.revision.fingerprint)
const objDup = await appendRevisionIfChanged({
serverId,
section: "firewall",
source: "rollback",
payload: objPayload,
})
assert.equal(objDup.created, false)
assert.equal(objDup.revision.source, "apply")
for (let i = 0; i < 4; i++) {
await appendRevisionIfChanged({
serverId,
section: "filters",
source: "apply",
payload: [{ community: `9:${i}`, action: "route" }],
})
}
const pruned = await pruneRevisions(serverId, "filters", 3)
assert.ok(pruned >= 1)
const after = await listRevisions(serverId, "filters")
assert.equal(after.length, 3)
} finally {
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
}
console.log("config-revisions.test.ts: ok")
+236
View File
@@ -0,0 +1,236 @@
import { createHash, randomUUID } from "node:crypto"
import { and, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { configRevisions, servers, type ConfigRevisionRow } from "../db/schema.js"
export const CONFIG_REVISION_KEEP = 50
export const CONFIG_SECTIONS = [
"filters",
"recursive-routes",
"firewall",
"wireguard",
"gre",
] as const
export type ConfigSection = (typeof CONFIG_SECTIONS)[number]
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
export interface ConfigRevisionDto {
id: string
serverId: string
section: ConfigSection
source: ConfigRevisionSource
fingerprint: string
createdAt: string
note: string | null
itemCount: number
}
export function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
const obj = value as Record<string, unknown>
const keys = Object.keys(obj).sort()
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`
}
export function fingerprintPayload(payload: unknown): string {
return createHash("sha256").update(stableStringify(payload)).digest("hex")
}
export function revisionItemCount(payload: unknown): number {
if (Array.isArray(payload)) return payload.length
if (payload && typeof payload === "object") {
let n = 0
for (const value of Object.values(payload as Record<string, unknown>)) {
if (Array.isArray(value)) n += value.length
}
return n
}
return 0
}
export function persistablePayload(payload: unknown): unknown {
if (payload === undefined) return []
return payload
}
export function canonicalFilterRules(
rules: Array<{
community?: string
action?: string
gateway?: string
gatewayTunnelId?: string
description?: string
}>,
): unknown[] {
return rules.map((r) => ({
community: (r.community ?? "").trim(),
action: r.action === "blackhole" ? "blackhole" : "route",
gateway: r.gateway ?? "",
gatewayTunnelId: r.gatewayTunnelId ?? "",
description: r.description ?? "",
}))
}
export function canonicalRecursiveRoutes(
routes: Array<{
dstAddress?: string
gateway?: string
distance?: number
scope?: number | null
targetScope?: number | null
routingTable?: string
checkGateway?: string
comment?: string
disabled?: boolean
country?: string
}>,
): unknown[] {
return routes.map((r) => ({
dstAddress: (r.dstAddress ?? "").trim(),
gateway: r.gateway ?? "",
distance: r.distance ?? 1,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
comment: r.comment ?? "",
disabled: Boolean(r.disabled),
country: r.country ?? "",
}))
}
export function toRevisionDto(row: ConfigRevisionRow): ConfigRevisionDto {
return {
id: row.id,
serverId: String(row.serverId),
section: row.section as ConfigSection,
source: row.source,
fingerprint: row.fingerprint,
createdAt: row.createdAt,
note: row.note ?? null,
itemCount: revisionItemCount(row.payload),
}
}
export async function listRevisions(
serverId: number,
section: ConfigSection,
limit = CONFIG_REVISION_KEEP,
): Promise<ConfigRevisionDto[]> {
const rows = await db
.select()
.from(configRevisions)
.where(and(eq(configRevisions.serverId, serverId), eq(configRevisions.section, section)))
.orderBy(desc(configRevisions.createdAt))
.limit(limit)
return rows.map(toRevisionDto)
}
export async function getRevisionById(id: string): Promise<ConfigRevisionRow | undefined> {
return (await db.select().from(configRevisions).where(eq(configRevisions.id, id)).limit(1))[0]
}
export async function pruneRevisions(
serverId: number,
section: ConfigSection,
keep = CONFIG_REVISION_KEEP,
): Promise<number> {
const rows = await db
.select({ id: configRevisions.id })
.from(configRevisions)
.where(and(eq(configRevisions.serverId, serverId), eq(configRevisions.section, section)))
.orderBy(desc(configRevisions.createdAt))
const extra = rows.slice(keep)
if (extra.length === 0) return 0
for (const row of extra) {
await db.delete(configRevisions).where(eq(configRevisions.id, row.id))
}
return extra.length
}
export async function appendRevisionIfChanged(input: {
serverId: number
section: ConfigSection
source: ConfigRevisionSource
payload: unknown
note?: string | null
}): Promise<{ created: boolean; revision: ConfigRevisionDto }> {
const payload = persistablePayload(input.payload)
const fingerprint = fingerprintPayload(payload)
const latest = (await db
.select()
.from(configRevisions)
.where(and(
eq(configRevisions.serverId, input.serverId),
eq(configRevisions.section, input.section),
))
.orderBy(desc(configRevisions.createdAt))
.limit(1))[0]
if (latest?.fingerprint === fingerprint) {
return { created: false, revision: toRevisionDto(latest) }
}
const now = new Date().toISOString()
const id = randomUUID()
await db.insert(configRevisions).values({
id,
serverId: input.serverId,
section: input.section,
source: input.source,
fingerprint,
payload,
note: input.note ?? null,
createdAt: now,
})
await pruneRevisions(input.serverId, input.section)
const row = await getRevisionById(id)
if (!row) throw new Error("config-revisions: insert vanished")
return { created: true, revision: toRevisionDto(row) }
}
/** После успешного mutate: capture live → append, ошибки snapshot не валят мутацию. */
export async function captureAndAppendRevision(input: {
serverId: number
section: ConfigSection
source: ConfigRevisionSource
capture: () => Promise<unknown>
note?: string | null
}): Promise<{ created: boolean; revision: ConfigRevisionDto } | null> {
try {
const payload = await input.capture()
return await appendRevisionIfChanged({
serverId: input.serverId,
section: input.section,
source: input.source,
payload,
note: input.note,
})
} catch {
return null
}
}
export async function loadRevisionForRestore(opts: {
id: string
section: ConfigSection
requestedServerId: number | null
}): Promise<
| { ok: true; row: ConfigRevisionRow; server: typeof servers.$inferSelect }
| { ok: false; status: number; error: string }
> {
const row = await getRevisionById(opts.id)
if (!row) return { ok: false, status: 404, error: "Revision not found" }
if (row.section !== opts.section) {
return { ok: false, status: 400, error: "Revision section mismatch" }
}
if (opts.requestedServerId !== null && opts.requestedServerId !== row.serverId) {
return { ok: false, status: 400, error: "Revision belongs to another server" }
}
const server = (await db.select().from(servers).where(eq(servers.id, row.serverId)).limit(1))[0]
if (!server) return { ok: false, status: 404, error: "Server not found" }
return { ok: true, row, server }
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import { mapContainerRow } from "./containers-live.js"
const server = {
id: 3,
name: "mt-spb",
host: "10.0.1.1",
} as Parameters<typeof mapContainerRow>[0]
const row = mapContainerRow(
server,
{
".id": "*A",
name: "adguard",
"remote-image": "adguard/adguardhome:latest",
interface: "veth-adguard",
envlist: "adguard-envs",
mounts: "agh-conf,agh-work",
status: "running",
"start-on-boot": "true",
comment: "DNS",
},
[
{ name: "adguard-envs", key: "FOO", value: "bar" },
{ name: "other", key: "SKIP", value: "x" },
],
[
{ name: "agh-conf", dst: "/opt/conf", src: "/disk1/conf" },
{ name: "agh-work", dst: "/opt/work" },
],
0,
)
assert.equal(row.rosId, "*A")
assert.equal(row.image, "adguard/adguardhome")
assert.equal(row.tag, "latest")
assert.equal(row.status, "running")
assert.deepEqual(row.interfaces, ["veth-adguard"])
assert.deepEqual(row.envs, [{ key: "FOO", value: "bar" }])
assert.deepEqual(row.mounts, [
{ dst: "/opt/conf", src: "/disk1/conf" },
{ dst: "/opt/work", src: undefined },
])
assert.equal(row.startOnBoot, true)
console.log("containers-live.test.ts: ok")
+215
View File
@@ -0,0 +1,215 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosContainer {
".id"?: string
name?: string
tag?: string
"remote-image"?: string
interface?: string
envlist?: string
mounts?: string
cmd?: string
"start-on-boot"?: string
comment?: string
status?: string
"memory-high"?: string
cpu?: string
}
interface RosContainerEnv {
name?: string
key?: string
value?: string
}
interface RosContainerMount {
name?: string
src?: string
dst?: string
}
export type ContainerLiveStatus = "running" | "stopped" | "error"
export type ContainerLive = {
id: string
rosId: string
name: string
serverId: string
image: string
tag: string
status: ContainerLiveStatus
envs: { key: string; value: string }[]
mounts: { dst: string; src?: string }[]
interfaces: string[]
cmd?: string
startOnBoot: boolean
comment: string
uptime?: string
cpu?: number
memMb?: number
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function mapStatus(raw: string | undefined): ContainerLiveStatus {
const s = (raw ?? "").toLowerCase()
if (s === "running") return "running"
if (s === "error" || s === "failed") return "error"
return "stopped"
}
function splitCsv(v: string | undefined): string[] {
return (v ?? "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
}
function parseImageTag(c: RosContainer): { image: string; tag: string } {
const remote = (c["remote-image"] ?? "").trim()
if (remote) {
const idx = remote.lastIndexOf(":")
if (idx > 0 && !remote.slice(idx + 1).includes("/")) {
return { image: remote.slice(0, idx), tag: remote.slice(idx + 1) }
}
return { image: remote, tag: (c.tag ?? "latest").trim() || "latest" }
}
return { image: (c.name ?? "").trim(), tag: (c.tag ?? "latest").trim() || "latest" }
}
function isMissingPackage(err: unknown): boolean {
if (err instanceof MikrotikError) {
if (err.statusCode === 404) return true
const body = err.body.toLowerCase()
return body.includes("no such command") || body.includes("not found") || body.includes("unknown")
}
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
return msg.includes("no such command") || msg.includes("404")
}
export function mapContainerRow(
server: ServerRow,
c: RosContainer,
envs: RosContainerEnv[],
mounts: RosContainerMount[],
idx: number,
): ContainerLive {
const rosId = String(c[".id"] ?? `c-${idx}`)
const name = (c.name ?? "").trim() || `container-${idx + 1}`
const { image, tag } = parseImageTag(c)
const envlist = (c.envlist ?? "").trim()
const mountNames = new Set(splitCsv(c.mounts))
const envRows = envlist
? envs.filter((e) => (e.name ?? "").trim() === envlist && (e.key ?? "").trim())
: []
const mountRows = mounts.filter((m) => mountNames.has((m.name ?? "").trim()) && (m.dst ?? "").trim())
const cpuRaw = Number.parseInt(c.cpu ?? "", 10)
const memRaw = Number.parseInt(c["memory-high"] ?? "", 10)
return {
id: `${server.id}-${rosId}`,
rosId,
name,
serverId: String(server.id),
image,
tag,
status: mapStatus(c.status),
envs: envRows.map((e) => ({ key: e.key ?? "", value: e.value ?? "" })),
mounts: mountRows.map((m) => ({ dst: m.dst ?? "", src: m.src || undefined })),
interfaces: splitCsv(c.interface),
cmd: (c.cmd ?? "").trim() || undefined,
startOnBoot: rosYes(c["start-on-boot"]),
comment: c.comment ?? "",
cpu: Number.isFinite(cpuRaw) ? cpuRaw : undefined,
memMb: Number.isFinite(memRaw) ? Math.round(memRaw / (1024 * 1024)) || undefined : undefined,
}
}
async function fetchContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
const client = MikrotikClient.fromServer(server)
try {
const [raw, envsRaw, mountsRaw] = await Promise.all([
client.get<RosContainer[]>("/container"),
client.get<RosContainerEnv[]>("/container/envs").catch(() => [] as RosContainerEnv[]),
client.get<RosContainerMount[]>("/container/mounts").catch(() => [] as RosContainerMount[]),
])
const list = Array.isArray(raw) ? raw : []
const envs = Array.isArray(envsRaw) ? envsRaw : []
const mounts = Array.isArray(mountsRaw) ? mountsRaw : []
return list.map((c, idx) => mapContainerRow(server, c, envs, mounts, idx))
} catch (err) {
if (isMissingPackage(err)) return []
throw err
}
}
export async function listContainers(): Promise<ContainerLive[]> {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
return await fetchContainersForServer(server)
} catch {
return [] as ContainerLive[]
}
}),
)
return results.flat()
}
export async function listContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
try {
return await fetchContainersForServer(server)
} catch {
return []
}
}
export async function countContainers(): Promise<number> {
try {
const result = await Promise.race([
listContainers(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.length
} catch {
return 0
}
}
function encodeRosId(rosId: string): string {
return encodeURIComponent(rosId)
}
export async function startContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.post("/container/start", { ".id": rosId })
}
export async function stopContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.post("/container/stop", { ".id": rosId })
}
export async function restartContainer(server: ServerRow, rosId: string): Promise<void> {
await stopContainer(server, rosId)
await startContainer(server, rosId)
}
export async function removeContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.delete(`/container/${encodeRosId(rosId)}`)
}
export async function getEnabledServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
@@ -0,0 +1,164 @@
import assert from "node:assert/strict"
import {
canonicalFirewallSnapshot,
canonicalGreSnapshot,
canonicalWireguardSnapshot,
opsPaths,
opsTouchOnly,
planFirewallRestore,
planGreCreate,
planGreDelete,
planGreRestore,
planWireguardRestore,
} from "./entity-snapshots.js"
import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
{
const a = canonicalFirewallSnapshot({
rules: [{ family: "ip", table: "filter", chain: "input", action: "accept", comment: "ssh" }],
addressLists: [{ family: "ip", list: "vip", address: "1.1.1.1" }],
})
const b = canonicalFirewallSnapshot({
rules: [{ family: "ip", table: "filter", chain: "input", action: "accept", comment: "ssh" }],
addressLists: [{ family: "ip", list: "vip", address: "1.1.1.1" }],
})
assert.equal(fingerprintPayload(a), fingerprintPayload(b))
assert.equal(revisionItemCount(a), 2)
}
{
const desired = canonicalFirewallSnapshot({
rules: [{ family: "ip", table: "filter", chain: "input", action: "accept", comment: "keep" }],
addressLists: [],
})
const ops = planFirewallRestore(desired, {
rules: [
{
...desired.rules[0]!,
rosId: "*1",
dynamic: false,
},
{
family: "ip",
table: "filter",
chain: "forward",
action: "drop",
protocol: "",
srcAddress: "",
dstAddress: "",
srcAddressList: "",
dstAddressList: "",
srcPort: "",
dstPort: "",
inInterface: "",
outInterface: "",
connectionState: "",
comment: "extra",
disabled: false,
log: false,
logPrefix: "",
tlsHost: "",
layer7Proto: "",
rosId: "*2",
dynamic: false,
},
],
addressLists: [],
})
assert.ok(ops.some((op) => op.op === "delete" && op.path.includes("/ip/firewall/filter/")))
assert.equal(opsTouchOnly(ops, ["/ip/firewall", "/ipv6/firewall"]), true)
assert.equal(opsPaths(ops).some((p) => p.startsWith("/ip/route") || p.startsWith("/interface/wireguard")), false)
}
{
const snap = canonicalWireguardSnapshot({
interfaces: [{
name: "wg0",
privateKey: "abc",
address: "10.8.0.1/24",
peers: [{ publicKey: "pk", allowedAddresses: ["10.8.0.2/32"] }],
}],
})
const ops = planWireguardRestore(snap, {
ifaces: [{ name: "wg0", rosId: "*w", listenPort: 13231, mtu: 1420, privateKey: "abc", comment: "", disabled: false }],
peers: [{
rosId: "*p",
interfaceName: "wg0",
publicKey: "old",
allowedAddresses: ["0.0.0.0/0"],
endpointAddress: "",
endpointPort: "",
persistentKeepalive: null,
comment: "",
name: "",
disabled: false,
privateKey: "",
clientAddress: "",
clientDns: "",
clientEndpoint: "",
}],
addrs: [{ rosId: "*a", interfaceName: "wg0", address: "10.8.0.1/24" }],
})
assert.ok(ops.some((op) => op.op === "delete" && op.path.includes("/interface/wireguard/peers/")))
assert.ok(ops.some((op) => op.op === "put" && op.path === "/interface/wireguard/peers"))
assert.equal(opsTouchOnly(ops, ["/interface/wireguard", "/ip/address"]), true)
assert.equal(opsPaths(ops).some((p) => p.startsWith("/interface/gre") || p.startsWith("/ip/route")), false)
}
{
const tunnel = canonicalGreSnapshot({
tunnels: [{
name: "gre-a",
remoteAddress: "203.0.113.1",
localInnerIp: "10.200.0.1/30",
ipsecSecret: "psk-secret",
}],
}).tunnels[0]!
const create = planGreCreate(tunnel)
assert.deepEqual(create.map((op) => op.op), ["put", "put"])
assert.equal(create[0]?.path, "/interface/gre")
assert.equal(create[1]?.path, "/ip/address")
assert.equal(create[1] && create[1].op === "put" ? create[1].body.interface : "", "gre-a")
assert.equal(opsPaths(create).some((p) => p.includes("gre-b")), false)
const del = planGreDelete("gre-a", {
gre: [
{ name: "gre-a", rosId: "*1", localAddress: "", remoteAddress: "203.0.113.1", comment: "", disabled: false, mtu: 1476, keepalive: "0", dscp: "inherit", clampTcpMss: true, allowFastPath: true, ipsecSecret: "" },
{ name: "gre-b", rosId: "*2", localAddress: "", remoteAddress: "203.0.113.2", comment: "", disabled: false, mtu: 1476, keepalive: "0", dscp: "inherit", clampTcpMss: true, allowFastPath: true, ipsecSecret: "" },
],
addrs: [
{ rosId: "*a1", interfaceName: "gre-a", address: "10.200.0.1/30" },
{ rosId: "*a2", interfaceName: "gre-b", address: "10.200.0.5/30" },
],
})
assert.ok(del.some((op) => op.path === "/ip/address/*a1"))
assert.ok(del.some((op) => op.path === "/interface/gre/*1"))
assert.equal(opsPaths(del).some((p) => p.includes("*2") || p.includes("*a2")), false)
const restore = planGreRestore(
canonicalGreSnapshot({ tunnels: [tunnel] }),
{
gre: [
{ name: "gre-a", rosId: "*1", localAddress: "", remoteAddress: "203.0.113.1", comment: "", disabled: false, mtu: 1476, keepalive: "0", dscp: "inherit", clampTcpMss: true, allowFastPath: true, ipsecSecret: "psk-secret" },
{ name: "gre-b", rosId: "*2", localAddress: "", remoteAddress: "203.0.113.2", comment: "", disabled: false, mtu: 1476, keepalive: "0", dscp: "inherit", clampTcpMss: true, allowFastPath: true, ipsecSecret: "" },
],
addrs: [
{ rosId: "*a1", interfaceName: "gre-a", address: "10.200.0.1/30" },
{ rosId: "*a2", interfaceName: "gre-b", address: "10.200.0.5/30" },
],
},
)
assert.ok(restore.some((op) => op.path === "/interface/gre/*2"))
assert.ok(restore.some((op) => op.path === "/ip/address/*a2"))
assert.equal(opsTouchOnly(restore, ["/interface/gre", "/ip/address"]), true)
}
{
const p1 = fingerprintPayload({ tunnels: [{ name: "gre-a", mtu: 1476 }] })
const p2 = fingerprintPayload({ tunnels: [{ name: "gre-a", mtu: 1476 }] })
const p3 = fingerprintPayload({ tunnels: [{ name: "gre-a", mtu: 1400 }] })
assert.equal(p1, p2)
assert.notEqual(p1, p3)
}
console.log("entity-snapshots.test.ts: ok")
+649
View File
@@ -0,0 +1,649 @@
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE. */
export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
export type RosWriteOp =
| { op: "put"; path: string; body: Record<string, string> }
| { op: "post"; path: string; body: Record<string, string> }
| { op: "patch"; path: string; body: Record<string, string> }
| { op: "delete"; path: string }
| { op: "move"; path: string; body: Record<string, string> }
export interface FirewallSnapshotRule {
family: FirewallFamily
table: FirewallTable
chain: string
action: string
protocol: string
srcAddress: string
dstAddress: string
srcAddressList: string
dstAddressList: string
srcPort: string
dstPort: string
inInterface: string
outInterface: string
connectionState: string
comment: string
disabled: boolean
log: boolean
logPrefix: string
tlsHost: string
layer7Proto: string
}
export interface FirewallSnapshotList {
family: FirewallFamily
list: string
address: string
comment: string
disabled: boolean
timeout: string
}
export interface FirewallSnapshot {
rules: FirewallSnapshotRule[]
addressLists: FirewallSnapshotList[]
}
export interface FirewallLiveRule extends FirewallSnapshotRule {
rosId: string
dynamic: boolean
}
export interface FirewallLiveList extends FirewallSnapshotList {
rosId: string
dynamic: boolean
}
export interface WgSnapshotPeer {
publicKey: string
allowedAddresses: string[]
endpointAddress: string
endpointPort: string
persistentKeepalive: number | null
comment: string
name: string
disabled: boolean
privateKey: string
clientAddress: string
clientDns: string
clientEndpoint: string
}
export interface WgSnapshotIface {
name: string
listenPort: number
mtu: number
privateKey: string
address: string
comment: string
disabled: boolean
peers: WgSnapshotPeer[]
}
export interface WgSnapshot {
interfaces: WgSnapshotIface[]
}
export interface WgLiveIface {
name: string
rosId: string
listenPort: number
mtu: number
privateKey: string
comment: string
disabled: boolean
}
export interface WgLivePeer {
rosId: string
interfaceName: string
publicKey: string
allowedAddresses: string[]
endpointAddress: string
endpointPort: string
persistentKeepalive: number | null
comment: string
name: string
disabled: boolean
privateKey: string
clientAddress: string
clientDns: string
clientEndpoint: string
}
export interface WgLiveAddr {
rosId: string
interfaceName: string
address: string
}
export interface GreSnapshotTunnel {
name: string
localAddress: string
remoteAddress: string
localInnerIp: string
remoteInnerIp: string
comment: string
disabled: boolean
mtu: number
keepalive: string
dscp: string
clampTcpMss: boolean
allowFastPath: boolean
ipsecSecret: string
}
export interface GreSnapshot {
tunnels: GreSnapshotTunnel[]
}
export interface GreLiveIface {
name: string
rosId: string
localAddress: string
remoteAddress: string
comment: string
disabled: boolean
mtu: number
keepalive: string
dscp: string
clampTcpMss: boolean
allowFastPath: boolean
ipsecSecret: string
}
export interface GreLiveAddr {
rosId: string
interfaceName: string
address: string
}
function str(v: unknown): string {
return String(v ?? "").trim()
}
function bool(v: unknown): boolean {
if (typeof v === "boolean") return v
const s = str(v).toLowerCase()
return s === "true" || s === "yes" || s === "1"
}
function num(v: unknown, fallback: number): number {
const n = typeof v === "number" ? v : Number.parseInt(str(v), 10)
return Number.isFinite(n) ? n : fallback
}
export function isHiddenSecret(value: string | undefined): boolean {
const s = str(value)
if (!s) return true
if (s === "(hidden)") return true
return /^\*+$/.test(s)
}
export function firewallRestPath(
family: FirewallFamily,
table: FirewallTable | "address-list",
): string {
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
return `${root}/${table}`
}
function rosYesNo(v: boolean | undefined): string | undefined {
if (v === true) return "yes"
if (v === false) return "no"
return undefined
}
function compactBody(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 canonicalFirewallSnapshot(input: {
rules?: Array<Partial<FirewallSnapshotRule>>
addressLists?: Array<Partial<FirewallSnapshotList>>
}): FirewallSnapshot {
const rules = (input.rules ?? []).map((r) => ({
family: r.family === "ip6" ? "ip6" as const : "ip" as const,
table: (["filter", "nat", "mangle", "raw"] as const).includes(r.table as FirewallTable)
? (r.table as FirewallTable)
: "filter",
chain: str(r.chain),
action: str(r.action),
protocol: str(r.protocol),
srcAddress: str(r.srcAddress),
dstAddress: str(r.dstAddress),
srcAddressList: str(r.srcAddressList),
dstAddressList: str(r.dstAddressList),
srcPort: str(r.srcPort),
dstPort: str(r.dstPort),
inInterface: str(r.inInterface),
outInterface: str(r.outInterface),
connectionState: str(r.connectionState),
comment: str(r.comment),
disabled: Boolean(r.disabled),
log: Boolean(r.log),
logPrefix: str(r.logPrefix),
tlsHost: str(r.tlsHost),
layer7Proto: str(r.layer7Proto),
}))
const addressLists = (input.addressLists ?? []).map((e) => ({
family: e.family === "ip6" ? "ip6" as const : "ip" as const,
list: str(e.list),
address: str(e.address),
comment: str(e.comment),
disabled: Boolean(e.disabled),
timeout: str(e.timeout),
}))
return { rules, addressLists }
}
export function parseFirewallSnapshot(payload: unknown): FirewallSnapshot {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return { rules: [], addressLists: [] }
}
const o = payload as Record<string, unknown>
return canonicalFirewallSnapshot({
rules: Array.isArray(o.rules) ? o.rules as Partial<FirewallSnapshotRule>[] : [],
addressLists: Array.isArray(o.addressLists) ? o.addressLists as Partial<FirewallSnapshotList>[] : [],
})
}
function firewallRuleKey(r: FirewallSnapshotRule): string {
return [
r.family, r.table, r.chain, r.action, r.protocol,
r.srcAddress, r.dstAddress, r.srcAddressList, r.dstAddressList,
r.srcPort, r.dstPort, r.inInterface, r.outInterface, r.connectionState,
r.comment, r.disabled ? "1" : "0", r.log ? "1" : "0", r.logPrefix, r.tlsHost, r.layer7Proto,
].join("\0")
}
function firewallListKey(e: FirewallSnapshotList): string {
return [e.family, e.list, e.address, e.comment, e.disabled ? "1" : "0", e.timeout].join("\0")
}
function firewallRuleBody(r: FirewallSnapshotRule): Record<string, string> {
return compactBody({
chain: r.chain,
action: r.action,
protocol: r.protocol && r.protocol !== "all" ? r.protocol : undefined,
"src-address": r.srcAddress,
"dst-address": r.dstAddress,
"src-address-list": r.srcAddressList,
"dst-address-list": r.dstAddressList,
"src-port": r.srcPort,
"dst-port": r.dstPort,
"in-interface": r.inInterface,
"out-interface": r.outInterface,
"connection-state": r.connectionState,
comment: r.comment,
disabled: rosYesNo(r.disabled),
log: rosYesNo(r.log),
"log-prefix": r.logPrefix,
"tls-host": r.tlsHost,
"layer7-protocol": r.layer7Proto,
})
}
export function planFirewallRestore(
desiredInput: FirewallSnapshot,
current: { rules: FirewallLiveRule[]; addressLists: FirewallLiveList[] },
): RosWriteOp[] {
const desired = canonicalFirewallSnapshot(desiredInput)
const ops: RosWriteOp[] = []
const usedRules = new Set<string>()
const usedLists = new Set<string>()
for (const live of current.rules) {
if (live.dynamic) continue
const key = firewallRuleKey(live)
const stillWanted = desired.rules.some((d) => firewallRuleKey(d) === key)
if (!stillWanted) {
ops.push({
op: "delete",
path: `${firewallRestPath(live.family, live.table)}/${live.rosId}`,
})
} else {
usedRules.add(key)
}
}
for (const live of current.addressLists) {
if (live.dynamic) continue
const key = firewallListKey(live)
const stillWanted = desired.addressLists.some((d) => firewallListKey(d) === key)
if (!stillWanted) {
ops.push({
op: "delete",
path: `${firewallRestPath(live.family, "address-list")}/${live.rosId}`,
})
} else {
usedLists.add(key)
}
}
for (const rule of desired.rules) {
if (usedRules.has(firewallRuleKey(rule))) continue
ops.push({
op: "put",
path: firewallRestPath(rule.family, rule.table),
body: firewallRuleBody(rule),
})
}
for (const entry of desired.addressLists) {
if (usedLists.has(firewallListKey(entry))) continue
ops.push({
op: "put",
path: firewallRestPath(entry.family, "address-list"),
body: compactBody({
list: entry.list,
address: entry.address,
comment: entry.comment,
timeout: entry.timeout,
disabled: rosYesNo(entry.disabled),
}),
})
}
return ops
}
function canonicalPeer(p: Partial<WgSnapshotPeer>): WgSnapshotPeer {
const allowed = Array.isArray(p.allowedAddresses)
? p.allowedAddresses.map((a) => str(a)).filter(Boolean)
: str((p as { allowedIps?: unknown }).allowedIps)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
return {
publicKey: str(p.publicKey),
allowedAddresses: allowed,
endpointAddress: str(p.endpointAddress),
endpointPort: str(p.endpointPort),
persistentKeepalive: p.persistentKeepalive == null ? null : num(p.persistentKeepalive, 0) || null,
comment: str(p.comment),
name: str(p.name),
disabled: Boolean(p.disabled),
privateKey: str(p.privateKey),
clientAddress: str(p.clientAddress),
clientDns: str(p.clientDns),
clientEndpoint: str(p.clientEndpoint),
}
}
export function canonicalWireguardSnapshot(input: {
interfaces?: Array<Partial<WgSnapshotIface> & { peers?: Array<Partial<WgSnapshotPeer>> }>
}): WgSnapshot {
const interfaces = (input.interfaces ?? [])
.map((iface) => ({
name: str(iface.name),
listenPort: num(iface.listenPort, 13231),
mtu: num(iface.mtu, 1420),
privateKey: str(iface.privateKey),
address: str(iface.address),
comment: str(iface.comment),
disabled: Boolean(iface.disabled),
peers: (iface.peers ?? []).map(canonicalPeer).sort((a, b) => a.publicKey.localeCompare(b.publicKey)),
}))
.filter((i) => i.name)
.sort((a, b) => a.name.localeCompare(b.name))
return { interfaces }
}
export function parseWireguardSnapshot(payload: unknown): WgSnapshot {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return { interfaces: [] }
}
const o = payload as Record<string, unknown>
return canonicalWireguardSnapshot({
interfaces: Array.isArray(o.interfaces)
? o.interfaces as Array<Partial<WgSnapshotIface> & { peers?: Array<Partial<WgSnapshotPeer>> }>
: [],
})
}
function peerBody(interfaceName: string, p: WgSnapshotPeer): Record<string, string> {
return compactBody({
interface: interfaceName,
"public-key": p.publicKey,
"allowed-address": p.allowedAddresses.join(","),
"endpoint-address": p.endpointAddress,
"endpoint-port": p.endpointPort,
"persistent-keepalive": p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
comment: p.comment,
name: p.name,
"private-key": isHiddenSecret(p.privateKey) ? undefined : p.privateKey,
"client-address": p.clientAddress,
"client-dns": p.clientDns,
"client-endpoint": p.clientEndpoint,
disabled: rosYesNo(p.disabled),
})
}
export function planWireguardRestore(
desiredInput: WgSnapshot,
current: { ifaces: WgLiveIface[]; peers: WgLivePeer[]; addrs: WgLiveAddr[] },
): RosWriteOp[] {
const desired = canonicalWireguardSnapshot(desiredInput)
const wantedNames = new Set(desired.interfaces.map((i) => i.name))
const ops: RosWriteOp[] = []
for (const peer of current.peers) {
const iface = desired.interfaces.find((i) => i.name === peer.interfaceName)
const keep = iface?.peers.some((p) => p.publicKey === peer.publicKey)
if (!keep) {
ops.push({ op: "delete", path: `/interface/wireguard/peers/${peer.rosId}` })
}
}
for (const addr of current.addrs) {
if (!wantedNames.has(addr.interfaceName)) {
ops.push({ op: "delete", path: `/ip/address/${addr.rosId}` })
}
}
for (const iface of current.ifaces) {
if (!wantedNames.has(iface.name)) {
ops.push({ op: "delete", path: `/interface/wireguard/${iface.rosId}` })
}
}
for (const want of desired.interfaces) {
const live = current.ifaces.find((i) => i.name === want.name)
const ifaceBody = compactBody({
name: want.name,
"listen-port": String(want.listenPort),
mtu: String(want.mtu),
"private-key": isHiddenSecret(want.privateKey) ? undefined : want.privateKey,
comment: want.comment,
disabled: rosYesNo(want.disabled),
})
if (!live) {
ops.push({ op: "put", path: "/interface/wireguard", body: ifaceBody })
} else {
ops.push({
op: "patch",
path: `/interface/wireguard/${live.rosId}`,
body: ifaceBody,
})
}
const liveAddr = current.addrs.find((a) => a.interfaceName === want.name)
if (want.address) {
if (!liveAddr) {
ops.push({ op: "put", path: "/ip/address", body: { address: want.address, interface: want.name } })
} else if (liveAddr.address !== want.address) {
ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` })
ops.push({ op: "put", path: "/ip/address", body: { address: want.address, interface: want.name } })
}
} else if (liveAddr) {
ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` })
}
for (const peer of want.peers) {
if (!peer.publicKey) continue
const livePeer = current.peers.find(
(p) => p.interfaceName === want.name && p.publicKey === peer.publicKey,
)
const body = peerBody(want.name, peer)
if (!livePeer) {
ops.push({ op: "put", path: "/interface/wireguard/peers", body })
} else {
ops.push({
op: "patch",
path: `/interface/wireguard/peers/${livePeer.rosId}`,
body,
})
}
}
}
return ops
}
export function canonicalGreSnapshot(input: {
tunnels?: Array<Partial<GreSnapshotTunnel>>
}): GreSnapshot {
const tunnels = (input.tunnels ?? [])
.map((t) => ({
name: str(t.name),
localAddress: str(t.localAddress),
remoteAddress: str(t.remoteAddress),
localInnerIp: str(t.localInnerIp),
remoteInnerIp: str(t.remoteInnerIp),
comment: str(t.comment),
disabled: Boolean(t.disabled),
mtu: num(t.mtu, 1476),
keepalive: str(t.keepalive) || "0",
dscp: str(t.dscp) || "inherit",
clampTcpMss: t.clampTcpMss !== false,
allowFastPath: t.allowFastPath !== false,
ipsecSecret: str(t.ipsecSecret),
}))
.filter((t) => t.name)
.sort((a, b) => a.name.localeCompare(b.name))
return { tunnels }
}
export function parseGreSnapshot(payload: unknown): GreSnapshot {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return { tunnels: [] }
}
const o = payload as Record<string, unknown>
return canonicalGreSnapshot({
tunnels: Array.isArray(o.tunnels) ? o.tunnels as Array<Partial<GreSnapshotTunnel>> : [],
})
}
export function greInterfaceBody(t: GreSnapshotTunnel): Record<string, string> {
return compactBody({
name: t.name,
"local-address": t.localAddress && t.localAddress !== "0.0.0.0" ? t.localAddress : undefined,
"remote-address": t.remoteAddress,
mtu: String(t.mtu),
keepalive: t.keepalive,
dscp: t.dscp,
"clamp-tcp-mss": t.clampTcpMss ? "yes" : "no",
"allow-fast-path": t.allowFastPath ? "yes" : "no",
comment: t.comment,
disabled: rosYesNo(t.disabled),
"ipsec-secret": isHiddenSecret(t.ipsecSecret) ? undefined : t.ipsecSecret,
})
}
export function planGreCreate(tunnel: GreSnapshotTunnel): RosWriteOp[] {
const t = canonicalGreSnapshot({ tunnels: [tunnel] }).tunnels[0]
if (!t) return []
const ops: RosWriteOp[] = [
{ op: "put", path: "/interface/gre", body: greInterfaceBody(t) },
]
if (t.localInnerIp) {
ops.push({
op: "put",
path: "/ip/address",
body: { address: t.localInnerIp, interface: t.name },
})
}
return ops
}
export function planGreDelete(
name: string,
current: { gre: GreLiveIface[]; addrs: GreLiveAddr[] },
): RosWriteOp[] {
const want = str(name)
const ops: RosWriteOp[] = []
for (const addr of current.addrs) {
if (addr.interfaceName === want) {
ops.push({ op: "delete", path: `/ip/address/${addr.rosId}` })
}
}
for (const gre of current.gre) {
if (gre.name === want) {
ops.push({ op: "delete", path: `/interface/gre/${gre.rosId}` })
}
}
return ops
}
export function planGreRestore(
desiredInput: GreSnapshot,
current: { gre: GreLiveIface[]; addrs: GreLiveAddr[] },
): RosWriteOp[] {
const desired = canonicalGreSnapshot(desiredInput)
const wanted = new Set(desired.tunnels.map((t) => t.name))
const ops: RosWriteOp[] = []
for (const gre of current.gre) {
if (!wanted.has(gre.name)) {
ops.push(...planGreDelete(gre.name, current))
}
}
for (const want of desired.tunnels) {
const live = current.gre.find((g) => g.name === want.name)
const body = greInterfaceBody(want)
if (!live) {
ops.push({ op: "put", path: "/interface/gre", body })
} else {
ops.push({ op: "patch", path: `/interface/gre/${live.rosId}`, body })
}
const liveAddr = current.addrs.find((a) => a.interfaceName === want.name)
if (want.localInnerIp) {
if (!liveAddr) {
ops.push({
op: "put",
path: "/ip/address",
body: { address: want.localInnerIp, interface: want.name },
})
} else if (liveAddr.address !== want.localInnerIp) {
ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` })
ops.push({
op: "put",
path: "/ip/address",
body: { address: want.localInnerIp, interface: want.name },
})
}
} else if (liveAddr) {
ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` })
}
}
return ops
}
export function opsPaths(ops: RosWriteOp[]): string[] {
return ops.map((op) => op.path)
}
export function opsTouchOnly(ops: RosWriteOp[], prefixes: string[]): boolean {
return ops.every((op) => prefixes.some((p) => op.path === p || op.path.startsWith(`${p}/`)))
}
+115 -7
View File
@@ -5,12 +5,21 @@ import {
MikrotikClient, MikrotikClient,
firewallRestPath, firewallRestPath,
} from "./mikrotik.js" } from "./mikrotik.js"
import {
captureAndAppendRevision,
} from "./config-revisions.js"
import type { import type {
FirewallFamily, FirewallFamily,
FirewallTable, FirewallTable,
RosFirewallAddressList, RosFirewallAddressList,
RosFirewallFilter, RosFirewallFilter,
} from "../types/server.js" } from "../types/server.js"
import {
canonicalFirewallSnapshot,
type FirewallLiveList,
type FirewallLiveRule,
type FirewallSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -150,29 +159,121 @@ async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T
} }
} }
export async function fetchServerFirewall(server: ServerRow): Promise<{ function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapFirewallSnapshotRule(
family: FirewallFamily,
table: FirewallTable,
raw: RosFirewallFilter,
): FirewallLiveRule {
return {
rosId: raw[".id"] || "",
dynamic: rosYes(raw.dynamic),
family,
table,
chain: raw.chain || "",
action: raw.action || "",
protocol: raw.protocol || "",
srcAddress: raw["src-address"] ?? "",
dstAddress: raw["dst-address"] ?? "",
srcAddressList: raw["src-address-list"] ?? "",
dstAddressList: raw["dst-address-list"] ?? "",
srcPort: raw["src-port"] ?? "",
dstPort: raw["dst-port"] ?? "",
inInterface: raw["in-interface"] ?? "",
outInterface: raw["out-interface"] ?? "",
connectionState: raw["connection-state"] ?? "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
log: rosYes(raw.log),
logPrefix: raw["log-prefix"] ?? "",
tlsHost: raw["tls-host"] ?? "",
layer7Proto: raw["layer7-protocol"] ?? "",
}
}
export function mapFirewallSnapshotList(
family: FirewallFamily,
raw: RosFirewallAddressList,
): FirewallLiveList {
return {
rosId: raw[".id"] || "",
dynamic: rosYes(raw.dynamic),
family,
list: raw.list || "",
address: raw.address || "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
timeout: raw.timeout ?? "",
}
}
export async function fetchFirewallState(server: ServerRow): Promise<{
rules: FirewallRuleDto[] rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[] addressLists: FirewallAddressListDto[]
liveRules: FirewallLiveRule[]
liveLists: FirewallLiveList[]
snapshot: FirewallSnapshot
}> { }> {
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
const ruleJobs = FAMILIES.flatMap((family) => const ruleJobs = FAMILIES.flatMap((family) =>
TABLES.map(async (table) => { TABLES.map(async (table) => {
const raw = await safeGet(() => client.getFirewallRules(family, table)) const raw = await safeGet(() => client.getFirewallRules(family, table))
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx)) return { family, table, raw }
}), }),
) )
const listJobs = FAMILIES.map(async (family) => { const listJobs = FAMILIES.map(async (family) => {
const raw = await safeGet(() => client.getFirewallAddressList(family)) const raw = await safeGet(() => client.getFirewallAddressList(family))
return raw.map((row, idx) => mapAddressList(server, family, row, idx)) return { family, raw }
}) })
const [ruleChunks, listChunks] = await Promise.all([ const [ruleChunks, listChunks] = await Promise.all([
Promise.all(ruleJobs), Promise.all(ruleJobs),
Promise.all(listJobs), Promise.all(listJobs),
]) ])
return {
rules: ruleChunks.flat(), const rules: FirewallRuleDto[] = []
addressLists: listChunks.flat(), const liveRules: FirewallLiveRule[] = []
for (const chunk of ruleChunks) {
chunk.raw.forEach((row, idx) => {
rules.push(mapFirewallRule(server, chunk.family, chunk.table, row, idx))
liveRules.push(mapFirewallSnapshotRule(chunk.family, chunk.table, row))
})
} }
const addressLists: FirewallAddressListDto[] = []
const liveLists: FirewallLiveList[] = []
for (const chunk of listChunks) {
chunk.raw.forEach((row, idx) => {
addressLists.push(mapAddressList(server, chunk.family, row, idx))
liveLists.push(mapFirewallSnapshotList(chunk.family, row))
})
}
return {
rules,
addressLists,
liveRules,
liveLists,
snapshot: canonicalFirewallSnapshot({
rules: liveRules.filter((r) => !r.dynamic),
addressLists: liveLists.filter((e) => !e.dynamic),
}),
}
}
export async function fetchServerFirewall(server: ServerRow): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const state = await fetchFirewallState(server)
return { rules: state.rules, addressLists: state.addressLists }
}
export async function captureFirewallSnapshot(server: ServerRow): Promise<FirewallSnapshot> {
const state = await fetchFirewallState(server)
return state.snapshot
} }
export async function listFirewallAll(): Promise<{ export async function listFirewallAll(): Promise<{
@@ -183,7 +284,14 @@ export async function listFirewallAll(): Promise<{
const perServer = await Promise.all( const perServer = await Promise.all(
allServers.map(async (server) => { allServers.map(async (server) => {
try { try {
return await fetchServerFirewall(server) const state = await fetchFirewallState(server)
await captureAndAppendRevision({
serverId: server.id,
section: "firewall",
source: "observed",
capture: async () => state.snapshot,
})
return { rules: state.rules, addressLists: state.addressLists }
} catch { } catch {
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] } return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
} }
+253
View File
@@ -0,0 +1,253 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import {
canonicalGreSnapshot,
type GreLiveAddr,
type GreLiveIface,
type GreSnapshot,
} from "./entity-snapshots.js"
import { captureAndAppendRevision } from "./config-revisions.js"
type ServerRow = typeof servers.$inferSelect
export interface RosGre {
".id"?: string
name?: string
"local-address"?: string
"remote-address"?: string
"allow-fast-path"?: string
"clamp-tcp-mss"?: string
mtu?: string
keepalive?: string
dscp?: string
running?: string
disabled?: string
comment?: string
"ipsec-secret"?: string
}
interface RosIpAddress {
".id"?: string
address?: string
interface?: string
disabled?: string
network?: string
}
export interface LiveGreTunnel {
id: string
rosId: string
name: string
serverId: string
localAddress: string
remoteAddress: string
localInnerIp: string
remoteInnerIp: string
poolId: string
ipsec: { secret: string } | null
mtu: number
keepaliveInterval: number
keepaliveRetries: number
dscp: "inherit" | number
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: "up" | "down" | "degraded"
}
export function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
const [intervalRaw, retriesRaw] = value.split(",")
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10)
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10)
return {
interval: Number.isFinite(interval) ? interval : 0,
retries: Number.isFinite(retries) ? retries : 0,
}
}
export function formatKeepalive(interval: number, retries: number): string {
if (!interval || interval <= 0) return "0"
return `${interval}s,${retries > 0 ? retries : 10}`
}
function parseDscp(value: string | undefined): "inherit" | number {
if (!value || value === "inherit") return "inherit"
const n = Number.parseInt(value, 10)
return Number.isFinite(n) ? n : "inherit"
}
function parseInnerFromComment(comment: string | undefined): { localInnerIp: string; remoteInnerIp: string } {
if (!comment) return { localInnerIp: "", remoteInnerIp: "" }
const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
const remote = comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
return { localInnerIp: local, remoteInnerIp: remote }
}
function rosDisabled(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapGreLive(
server: ServerRow,
greRaw: RosGre[],
addrsRaw: RosIpAddress[],
): {
tunnels: LiveGreTunnel[]
snapshot: GreSnapshot
gre: GreLiveIface[]
addrs: GreLiveAddr[]
} {
const addrsByIface = new Map<string, { address: string; rosId: string }[]>()
for (const a of addrsRaw) {
if (rosDisabled(a.disabled)) continue
const iface = (a.interface ?? "").trim()
const address = (a.address ?? "").trim()
const rosId = String(a[".id"] ?? "")
if (!iface || !address || !rosId) continue
const list = addrsByIface.get(iface) ?? []
list.push({ address, rosId })
addrsByIface.set(iface, list)
}
const gre: GreLiveIface[] = []
const addrs: GreLiveAddr[] = []
const tunnels: LiveGreTunnel[] = []
greRaw.forEach((g, idx) => {
const rosId = String(g[".id"] ?? g.name ?? `gre-${idx}`)
const name = (g.name ?? "").trim() || `gre-${idx + 1}`
const keepalive = parseKeepalive(g.keepalive)
const fromComment = parseInnerFromComment(g.comment)
const ifaceAddrs = addrsByIface.get(name) ?? []
const localInnerIp = ifaceAddrs[0]?.address || fromComment.localInnerIp
const secret = (g["ipsec-secret"] ?? "").trim()
const disabled = rosDisabled(g.disabled)
const running = g.running === "true" || g.running === "yes"
gre.push({
name,
rosId,
localAddress: g["local-address"] ?? "",
remoteAddress: g["remote-address"] ?? "",
comment: g.comment ?? "",
disabled,
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
keepalive: g.keepalive ?? "0",
dscp: g.dscp ?? "inherit",
clampTcpMss: g["clamp-tcp-mss"] !== "false" && g["clamp-tcp-mss"] !== "no",
allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no",
ipsecSecret: secret,
})
for (const a of ifaceAddrs) {
addrs.push({ rosId: a.rosId, interfaceName: name, address: a.address })
}
tunnels.push({
id: rosId || `${server.id}:${name}`,
rosId,
name,
serverId: String(server.id),
localAddress: g["local-address"] ?? "",
remoteAddress: g["remote-address"] ?? "",
localInnerIp,
remoteInnerIp: fromComment.remoteInnerIp,
poolId: "live",
ipsec: secret ? { secret } : null,
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
keepaliveInterval: keepalive.interval,
keepaliveRetries: keepalive.retries,
dscp: parseDscp(g.dscp),
clampTcpMss: g["clamp-tcp-mss"] !== "false" && g["clamp-tcp-mss"] !== "no",
allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no",
comment: g.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : running ? "up" : "degraded",
})
})
return {
tunnels,
snapshot: canonicalGreSnapshot({
tunnels: gre.map((g) => ({
name: g.name,
localAddress: g.localAddress,
remoteAddress: g.remoteAddress,
localInnerIp: addrs.find((a) => a.interfaceName === g.name)?.address ?? "",
remoteInnerIp: "",
comment: g.comment,
disabled: g.disabled,
mtu: g.mtu,
keepalive: g.keepalive,
dscp: g.dscp,
clampTcpMss: g.clampTcpMss,
allowFastPath: g.allowFastPath,
ipsecSecret: g.ipsecSecret,
})),
}),
gre,
addrs,
}
}
export async function fetchGreState(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [greRaw, addrsRaw] = await Promise.all([
client.get<RosGre[]>("/interface/gre"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
return {
client,
...mapGreLive(server, Array.isArray(greRaw) ? greRaw : [], Array.isArray(addrsRaw) ? addrsRaw : []),
}
}
export async function captureGreSnapshot(server: ServerRow): Promise<GreSnapshot> {
const state = await fetchGreState(server)
return state.snapshot
}
export async function listGreTunnels(opts?: { serverId?: string }): Promise<{
tunnels: LiveGreTunnel[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}> {
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { tunnels: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: Array<{ serverId: string; serverName?: string; error: string }> = []
const chunks = await Promise.all(
serverRows.map(async (server) => {
try {
const state = await fetchGreState(server)
await captureAndAppendRevision({
serverId: server.id,
section: "gre",
source: "observed",
capture: async () => state.snapshot,
})
return state.tunnels
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
return [] as LiveGreTunnel[]
}
}),
)
return { tunnels: chunks.flat(), failures }
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict"
import type { RosIpRoute } from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "./ospf-route-parse.js"
function route(partial: Partial<RosIpRoute>): RosIpRoute {
return { ".id": "*1", "dst-address": "10.0.0.0/8", ...partial }
}
assert.equal(parseOspfRouteType(route({ static: "true" })), null)
assert.equal(parseOspfRouteType(route({ bgp: "true" })), null)
assert.equal(parseOspfRouteType(route({ ospf: "true" })), "O")
assert.equal(parseOspfRouteType(route({ "ospf-type": "intra-area" })), "O")
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "inter-area" })), "O IA")
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "ext-type-1" })), "O E1")
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "type-2" })), "O E2")
assert.deepEqual(parseOspfGateway(route({ gateway: "10.200.0.1%gre-msk-spb" })), {
nextHop: "10.200.0.1",
via: "gre-msk-spb",
})
console.log("ospf-route-parse.test.ts: ok")
+28
View File
@@ -0,0 +1,28 @@
import type { RosIpRoute } from "../types/server.js"
export type OspfRouteKind = "O" | "O IA" | "O E1" | "O E2"
/** RouterOS /ip/route → тип OSPF-маршрута UI, либо null если маршрут не OSPF. */
export function parseOspfRouteType(r: RosIpRoute): OspfRouteKind | null {
const ospfFlag = r.ospf === "true" || r.ospf === "yes"
const raw = `${r["ospf-type"] ?? ""} ${r.type ?? ""}`.toLowerCase()
const looksOspf = ospfFlag || raw.includes("ospf") || Boolean(r["ospf-type"])
if (!looksOspf) return null
if (raw.includes("inter")) return "O IA"
if (raw.includes("e1") || raw.includes("type-1") || raw.includes("ext-1") || raw.includes("nssa-ext-type-1")) {
return "O E1"
}
if (raw.includes("e2") || raw.includes("type-2") || raw.includes("ext-2") || raw.includes("nssa-ext-type-2")) {
return "O E2"
}
return "O"
}
export function parseOspfGateway(r: RosIpRoute): { nextHop: string; via: string } {
const gw = (r.gateway ?? r["immediate-gw"] ?? "").trim()
const [ip, iface = ""] = gw.split("%")
return {
nextHop: ip || gw || "—",
via: iface || (r.interface ?? "—"),
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { MikrotikClient } from "./mikrotik.js"
import type { RosWriteOp } from "./entity-snapshots.js"
function encodeIdSegment(path: string): string {
const i = path.lastIndexOf("/")
if (i < 0) return path
const last = path.slice(i + 1)
if (!last.startsWith("*")) return path
return `${path.slice(0, i + 1)}${encodeURIComponent(last)}`
}
export async function executeRosOps(client: MikrotikClient, ops: RosWriteOp[]): Promise<void> {
for (const op of ops) {
if (op.op === "put") {
await client.put(op.path, op.body)
continue
}
if (op.op === "post") {
await client.post(encodeIdSegment(op.path), op.body)
continue
}
if (op.op === "patch") {
await client.patch(encodeIdSegment(op.path), op.body)
continue
}
if (op.op === "delete") {
await client.delete(encodeIdSegment(op.path))
continue
}
await client.post(encodeIdSegment(op.path), op.body)
}
}
+177 -19
View File
@@ -1,6 +1,8 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { getStatistics, getStatisticsPivot, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js" import { getStatistics, getStatisticsPivot, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js" import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import { setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import { withPgOrSkip } from "../test/pg.js" import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js" import { dbQuery } from "../db/index.js"
import { ensurePartitionFor } from "../db/partitions.js" import { ensurePartitionFor } from "../db/partitions.js"
@@ -28,16 +30,27 @@ if (!(await withPgOrSkip())) {
} }
const inserted = await dbQuery<{ id: number }>(` const inserted = await dbQuery<{ id: number }>(`
INSERT INTO servers (name, host) VALUES ('stats-cube', '127.0.0.1') RETURNING id INSERT INTO servers (name, host, type, wan_uplinks)
VALUES ('stats-cube', '127.0.0.1', 'jump-host', '[{"iface":"wan1"}]'::jsonb)
RETURNING id
`) `)
const serverId = inserted.rows[0]?.id const serverId = inserted.rows[0]?.id
if (serverId == null) throw new Error("no server") if (serverId == null) throw new Error("no server")
const enInserted = await dbQuery<{ id: number }>(`
INSERT INTO servers (name, host, type)
VALUES ('stats-en', '198.51.100.1', 'exit-node')
RETURNING id
`)
const enId = enInserted.rows[0]?.id
if (enId == null) throw new Error("no en server")
await ensurePartitionFor(pool, "flow_daily_facts", "month", new Date("2026-09-01T00:00:00Z")) await ensurePartitionFor(pool, "flow_daily_facts", "month", new Date("2026-09-01T00:00:00Z"))
await ensurePartitionFor(pool, "flow_hour_facts", "day", new Date("2026-09-10T00:00:00Z")) await ensurePartitionFor(pool, "flow_hour_facts", "day", new Date("2026-09-10T00:00:00Z"))
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId]) await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId]) await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId]) await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM server_snapshots WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM app_users WHERE id = 'u-stats-1'`) await dbQuery(`DELETE FROM app_users WHERE id = 'u-stats-1'`)
await dbQuery(` await dbQuery(`
@@ -50,28 +63,153 @@ await dbQuery(`
VALUES ('bind-stats-1', 'u-stats-1', $1, 'gre-client', 'gre') VALUES ('bind-stats-1', 'u-stats-1', $1, 'gre-client', 'gre')
`, [serverId]) `, [serverId])
await dbQuery(`
INSERT INTO server_snapshots (server_id, polled_at, status, raw_interfaces)
VALUES
($1, '2026-09-10T12:00:00Z', 'online', $3::jsonb),
($2, '2026-09-10T12:00:00Z', 'online', $4::jsonb)
`, [
serverId,
enId,
JSON.stringify([
{ name: "gre-client", type: "gre-tunnel" },
{ name: "wan1", type: "ether" },
{ name: "gre-en", type: "gre-tunnel" },
{ name: "NSK-SERVHOST-RTK", type: "gre-tunnel" },
{ name: "wg-mesh", type: "wg" },
{ name: "wg-server", type: "wg" },
{ name: "wg-flow", type: "wg" },
]),
JSON.stringify([
{ name: "ether1", type: "ether" },
{ name: "gre-jh", type: "gre-tunnel" },
]),
])
resetIfaceCacheForTests() resetIfaceCacheForTests()
rememberServerIfaces(serverId, [{ name: "gre-client", ifindex: "2" }]) rememberServerIfaces(serverId, [
{ name: "gre-client", ifindex: "2" },
{ name: "wan1", ifindex: "8" },
{ name: "gre-en", ifindex: "9" },
{ name: "NSK-SERVHOST-RTK" },
{ name: "wg-mesh" },
{ name: "wg-server" },
{ name: "wg-flow" },
])
rememberServerIfaces(enId, [
{ name: "ether1", ifindex: "2" },
{ name: "gre-jh", ifindex: "5" },
])
setRefreshIfacesForTests(async () => {})
invalidateFlowCatalogCache()
await dbQuery(` await dbQuery(`
INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets) INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets)
VALUES VALUES
($1, '2026-09-10', '2', 'US', 'https', 15169, 800, 10), ($1, '2026-09-10', '2', 'US', 'https', 15169, 800, 10),
($1, '2026-09-10', '2', 'DE', 'dns', 15133, 200, 4), ($1, '2026-09-10', '2', 'DE', 'dns', 15133, 200, 4),
($1, '2026-09-10', 'wan1', 'NL', 'other', 0, 70, 1) ($1, '2026-09-10', 'wan1', 'NL', 'other', 0, 70, 1),
`, [serverId]) ($1, '2026-09-10', '0', 'US', 'https', 0, 999, 3),
($1, '2026-09-10', 'gre-en', 'US', 'https', 15169, 400, 2),
($1, '2026-09-10', 'NSK-SERVHOST-RTK', 'US', 'https', 15169, 300, 2),
($1, '2026-09-10', 'wg-mesh', 'US', 'https', 0, 250, 2),
($1, '2026-09-10', 'wg-flow', 'US', 'https', 0, 80, 1),
($2, '2026-09-10', 'gre-jh', 'US', 'https', 15169, 500, 5),
($2, '2026-09-10', 'ether1', 'US', 'https', 15169, 200, 2)
`, [serverId, enId])
try { try {
const all = await getStatistics({ from: "2026-09-01", to: "2026-09-30" }) const unique = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" })
assert.equal(all.grain, "day") assert.equal(unique.grain, "day")
assert.equal(all.kpis.bytes, 1070) assert.equal(unique.kpis.bytes, 1000)
assert.equal(all.kpis.users, 1) const uniqueAsnSum = unique.asns.reduce((s, r) => s + r.bytes, 0)
assert.ok(all.countries.some((r) => r.id === "US")) assert.equal(uniqueAsnSum, unique.kpis.bytes, "unique KPI = SUM dest ASN")
assert.ok(all.users.some((r) => r.id === "u-stats-1")) assert.equal(unique.kpis.users, 1)
const unbound = all.users.find((r) => r.id === STATISTICS_UNBOUND_USER_ID) assert.ok(unique.countries.some((r) => r.id === "US"))
assert.ok(unbound) assert.ok(unique.users.some((r) => r.id === "u-stats-1"))
assert.equal(unbound.bytes, 70) assert.equal(unique.users.find((r) => r.id === STATISTICS_UNBOUND_USER_ID), undefined)
assert.ok(all.servers.some((r) => r.id === String(serverId))) assert.ok(unique.servers.some((r) => r.id === String(serverId)))
assert.ok(!unique.servers.some((r) => r.id === String(enId)), "EN-транзит не в сетевом KPI")
const greIface = unique.interfaces.find((r) => r.label.includes("gre-client"))
assert.ok(greIface)
assert.equal(greIface.bytes, 1000)
assert.equal(greIface.id, `${serverId}:gre-client`)
assert.ok(!unique.interfaces.some((r) => /· (?:#)?\d+$/.test(r.label)))
assert.ok(!unique.interfaces.some((r) => r.label.includes(" · —") || r.label.endsWith("· —")))
assert.ok(!unique.interfaces.some((r) => r.label.includes("gre-en")))
assert.ok(!unique.interfaces.some((r) => r.label.includes("NSK-SERVHOST-RTK")))
assert.ok(!unique.interfaces.some((r) => r.label.includes("wg-mesh")))
assert.ok(!unique.interfaces.some((r) => r.label.includes("wg-flow")))
assert.equal(unique.interfaces.find((r) => r.id === `${serverId}:wan1`), undefined, "unique без WAN")
const allPlanes = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "all" })
assert.equal(allPlanes.kpis.bytes, 1000, "KPI unique и all одинаковый")
const wanRow = allPlanes.interfaces.find((r) => r.id === `${serverId}:wan1`)
assert.ok(wanRow)
assert.ok(wanRow.label.includes("WAN · интернет"))
assert.equal(wanRow.bytes, 70)
assert.equal(wanRow.percent, 0)
const overlayGre = allPlanes.interfaces.find((r) => r.id === `${serverId}:gre-en`)
assert.ok(overlayGre)
assert.ok(overlayGre.label.includes("дубль"))
assert.equal(overlayGre.percent, 0)
const overlayCustom = allPlanes.interfaces.find((r) => r.label.includes("NSK-SERVHOST-RTK"))
assert.ok(overlayCustom)
assert.ok(overlayCustom.label.includes("дубль"))
const overlayWg = allPlanes.interfaces.find((r) => r.label.includes("wg-mesh"))
assert.ok(overlayWg)
assert.ok(overlayWg.label.includes("дубль"))
assert.ok(!allPlanes.interfaces.some((r) => r.label.includes("wg-flow")))
const wanSlice = await getStatistics({
from: "2026-09-01",
to: "2026-09-30",
serverId,
iface: "wan1",
})
assert.equal(wanSlice.kpis.bytes, 70)
const nodeSlice = await getStatistics({
from: "2026-09-01",
to: "2026-09-30",
serverId,
planes: "unique",
})
assert.equal(nodeSlice.kpis.bytes, 1000)
assert.equal(nodeSlice.interfaces.find((r) => r.id === `${serverId}:wan1`), undefined)
assert.ok(!nodeSlice.users.some((r) => r.id === STATISTICS_UNBOUND_USER_ID))
const nodeAll = await getStatistics({
from: "2026-09-01",
to: "2026-09-30",
serverId,
planes: "all",
})
assert.equal(nodeAll.kpis.bytes, 1000)
const nodeWan = nodeAll.interfaces.find((r) => r.id === `${serverId}:wan1`)
assert.ok(nodeWan)
assert.equal(nodeWan.percent, 0)
assert.ok(nodeWan.label.includes("WAN · интернет"))
const enSlice = await getStatistics({
from: "2026-09-01",
to: "2026-09-30",
serverId: enId,
planes: "unique",
})
assert.equal(enSlice.kpis.bytes, 0)
assert.ok(!enSlice.interfaces.some((r) => r.label.includes("gre-jh")))
assert.ok(!enSlice.interfaces.some((r) => r.label.includes("WAN · интернет")))
const enAll = await getStatistics({
from: "2026-09-01",
to: "2026-09-30",
serverId: enId,
planes: "all",
})
assert.equal(enAll.kpis.bytes, 0)
assert.ok(enAll.interfaces.some((r) => r.label.includes("WAN · интернет") && r.label.includes("ether1") && r.percent === 0))
assert.ok(enAll.interfaces.some((r) => r.label.includes("gre-jh") && r.label.includes("дубль")))
const sliced = await getStatistics({ const sliced = await getStatistics({
from: "2026-09-01", from: "2026-09-01",
@@ -106,6 +244,22 @@ try {
assert.equal(us.cells.https, 800) assert.equal(us.cells.https, 800)
assert.equal(de.cells.dns, 200) assert.equal(de.cells.dns, 200)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('bind-stats-wg', 'u-stats-1', $1, 'wg-server', 'wg')
`, [serverId])
await dbQuery(`
INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets)
VALUES ($1, '2026-09-10', 'wg-server', 'US', 'https', 15169, 150, 2)
`, [serverId])
invalidateFlowCatalogCache()
const withWg = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" })
assert.equal(withWg.kpis.bytes, 1150)
assert.ok(withWg.interfaces.some((r) => r.label.includes("wg-server") && r.bytes === 150))
assert.ok(!withWg.interfaces.some((r) => r.label.includes("wg-flow")))
assert.ok(!withWg.interfaces.some((r) => r.label.includes("wg-mesh")))
await dbQuery(` await dbQuery(`
INSERT INTO flow_hour_facts (server_id, bucket_at, iface, country, service, asn, bytes, packets) INSERT INTO flow_hour_facts (server_id, bucket_at, iface, country, service, asn, bytes, packets)
VALUES ($1, '2026-09-10T10:00:00Z', '2', 'US', 'https', 15169, 40, 2) VALUES ($1, '2026-09-10T10:00:00Z', '2', 'US', 'https', 15169, 40, 2)
@@ -118,10 +272,14 @@ try {
assert.equal(hourly.kpis.bytes, 40) assert.equal(hourly.kpis.bytes, 40)
assert.ok(hourly.users.some((r) => r.id === "u-stats-1")) assert.ok(hourly.users.some((r) => r.id === "u-stats-1"))
} finally { } finally {
setRefreshIfacesForTests(null)
resetIfaceCacheForTests() resetIfaceCacheForTests()
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId]) invalidateFlowCatalogCache()
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId]) await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId]) await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM server_snapshots WHERE server_id IN ($1, $2)`, [serverId, enId])
await dbQuery(`DELETE FROM servers WHERE id IN ($1, $2)`, [serverId, enId])
} }
console.log("statistics-aggregate.test.ts: ok") console.log("statistics-aggregate.test.ts: ok")
+268 -80
View File
@@ -11,10 +11,22 @@ import {
type StatisticsQuery, type StatisticsQuery,
} from "@mmapp/contracts/statistics" } from "@mmapp/contracts/statistics"
import { import {
bindingIfaceAliases, collapseServerIfaceRows,
bindingIfaceAliasesAllServers, displayFactIface,
expandBindingIfaces, expandBindingIfaces,
factIfaceAliases,
listCachedIfaceNames,
} from "./traffic-flow-ifindex.js" } from "./traffic-flow-ifindex.js"
import { refreshServerIfaces } from "./traffic-flow-ifaces.js"
import {
isDashDisplayIface,
isJunkFactIface,
isOverlayTunnelIface,
isWanFactIface,
overlayDupLabel,
wanIfaceLabel,
} from "./traffic-flow-facts-filter.js"
import { getServerCatalog, loadFlowTopology, type FlowTopology } from "./traffic-flow-topology.js"
const TOP_N = 200 const TOP_N = 200
const HOUR_WINDOW_MS = 48 * 3600_000 const HOUR_WINDOW_MS = 48 * 3600_000
@@ -76,6 +88,8 @@ export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPer
} }
} }
type FactScope = "unique" | "wan" | "overlay"
interface FilterCtx { interface FilterCtx {
fromIso: string fromIso: string
toIso: string toIso: string
@@ -86,19 +100,68 @@ interface FilterCtx {
country?: string country?: string
service?: string service?: string
asn?: number asn?: number
planes: "unique" | "all"
userIfaces: Array<{ serverId: number; iface: string }> | null userIfaces: Array<{ serverId: number; iface: string }> | null
unboundOnly: boolean unboundOnly: boolean
boundIfaces: Array<{ serverId: number; iface: string }> boundIfaces: Array<{ serverId: number; iface: string }>
overlayIfaces: Array<{ serverId: number; iface: string }>
wanIfaces: Array<{ serverId: number; iface: string }>
excludeServerIds: number[]
topo: FlowTopology | null
} }
function ifaceFilterAliases(iface: string, serverId?: number): string[] { function ifaceFilterAliases(iface: string, serverId?: number): string[] {
const raw = iface.trim() return factIfaceAliases(iface.trim(), serverId)
if (!raw) return []
if (serverId != null) return bindingIfaceAliases(serverId, raw)
return bindingIfaceAliasesAllServers(raw)
} }
function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql: string; params: unknown[] } { function looksLikeIfIndex(iface: string): boolean {
const raw = iface.trim()
return /^\d+$/.test(raw) || /^#\d+$/.test(raw)
}
async function warmIfaceCache(ids: Iterable<number>): Promise<void> {
const uniq = [...new Set(ids)].filter((id) => Number.isFinite(id) && id > 0)
if (!uniq.length) return
await Promise.all(uniq.map((id) => refreshServerIfaces(id)))
}
async function warmBindingIfaceCache(): Promise<void> {
const rows = await db.select({ serverId: userInterfaceBindings.serverId }).from(userInterfaceBindings)
await warmIfaceCache(rows.map((r) => r.serverId))
}
function canonicalIfaceDimId(id: string): string {
const colon = id.indexOf(":")
if (colon < 0) return id
const sid = Number(id.slice(0, colon))
if (!Number.isFinite(sid)) return id
return `${sid}:${displayFactIface(sid, id.slice(colon + 1))}`
}
function pushIfaceTuples(
parts: string[],
params: unknown[],
alias: string,
tuples: Array<{ serverId: number; iface: string }>,
op: "IN" | "NOT IN",
): void {
if (!tuples.length) {
if (op === "IN") parts.push("FALSE")
return
}
const sql = tuples.map(() => "(?, ?)").join(", ")
parts.push(`(${alias}.server_id, ${alias}.iface) ${op} (${sql})`)
for (const t of tuples) {
params.push(t.serverId, t.iface)
}
}
function factWhere(
alias: string,
grain: "hour" | "day",
ctx: FilterCtx,
scope: FactScope = "unique",
): { sql: string; params: unknown[] } {
const params: unknown[] = [] const params: unknown[] = []
const parts: string[] = [] const parts: string[] = []
if (grain === "hour") { if (grain === "hour") {
@@ -112,16 +175,6 @@ function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql:
parts.push(`${alias}.server_id = ?`) parts.push(`${alias}.server_id = ?`)
params.push(ctx.serverId) params.push(ctx.serverId)
} }
if (ctx.iface) {
const aliases = ifaceFilterAliases(ctx.iface, ctx.serverId)
if (aliases.length <= 1) {
parts.push(`${alias}.iface = ?`)
params.push(aliases[0] ?? ctx.iface)
} else {
parts.push(`${alias}.iface IN (${aliases.map(() => "?").join(", ")})`)
params.push(...aliases)
}
}
if (ctx.country) { if (ctx.country) {
parts.push(`${alias}.country = ?`) parts.push(`${alias}.country = ?`)
params.push(ctx.country.toUpperCase()) params.push(ctx.country.toUpperCase())
@@ -134,27 +187,40 @@ function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql:
parts.push(`${alias}.asn = ?`) parts.push(`${alias}.asn = ?`)
params.push(ctx.asn) params.push(ctx.asn)
} }
if (ctx.userIfaces) { parts.push(`${alias}.iface NOT IN ('0', '—', '__unknown__', 'wg-flow', '')`)
if (ctx.userIfaces.length === 0) {
parts.push("FALSE") if (scope === "wan") {
pushIfaceTuples(parts, params, alias, ctx.wanIfaces, "IN")
return { sql: parts.join(" AND "), params }
}
if (scope === "overlay") {
pushIfaceTuples(parts, params, alias, ctx.overlayIfaces, "IN")
return { sql: parts.join(" AND "), params }
}
if (ctx.iface) {
const aliases = ifaceFilterAliases(ctx.iface, ctx.serverId)
if (aliases.length <= 1) {
parts.push(`${alias}.iface = ?`)
params.push(aliases[0] ?? ctx.iface)
} else { } else {
const tuples = ctx.userIfaces.map(() => "(?, ?)").join(", ") parts.push(`${alias}.iface IN (${aliases.map(() => "?").join(", ")})`)
parts.push(`(${alias}.server_id, ${alias}.iface) IN (${tuples})`) params.push(...aliases)
for (const u of ctx.userIfaces) {
params.push(u.serverId, u.iface)
}
} }
return { sql: parts.join(" AND "), params }
}
if (ctx.userIfaces) {
pushIfaceTuples(parts, params, alias, ctx.userIfaces, "IN")
return { sql: parts.join(" AND "), params }
} }
if (ctx.unboundOnly) { if (ctx.unboundOnly) {
if (ctx.boundIfaces.length === 0) { parts.push("FALSE")
/* весь трафик без привязок */ return { sql: parts.join(" AND "), params }
} else { }
const tuples = ctx.boundIfaces.map(() => "(?, ?)").join(", ") pushIfaceTuples(parts, params, alias, ctx.boundIfaces, "IN")
parts.push(`(${alias}.server_id, ${alias}.iface) NOT IN (${tuples})`) if (ctx.excludeServerIds.length) {
for (const u of ctx.boundIfaces) { parts.push(`${alias}.server_id NOT IN (${ctx.excludeServerIds.map(() => "?").join(", ")})`)
params.push(u.serverId, u.iface) params.push(...ctx.excludeServerIds)
}
}
} }
return { sql: parts.join(" AND "), params } return { sql: parts.join(" AND "), params }
} }
@@ -214,7 +280,7 @@ async function loadBindUserTuples(): Promise<UserBindTuple[]> {
const seen = new Set<string>() const seen = new Set<string>()
const out: UserBindTuple[] = [] const out: UserBindTuple[] = []
for (const b of binds) { for (const b of binds) {
for (const iface of bindingIfaceAliases(b.serverId, b.interfaceName)) { for (const iface of factIfaceAliases(b.interfaceName, b.serverId)) {
const k = `${b.userId}\0${b.serverId}\0${iface}` const k = `${b.userId}\0${b.serverId}\0${iface}`
if (seen.has(k)) continue if (seen.has(k)) continue
seen.add(k) seen.add(k)
@@ -251,12 +317,64 @@ function userBindJoinSql(tuples: UserBindTuple[]): { sql: string; params: unknow
} }
} }
function expandIfaceTuples(
items: Array<{ serverId: number; iface: string }>,
): Array<{ serverId: number; iface: string }> {
const seen = new Set<string>()
const out: Array<{ serverId: number; iface: string }> = []
for (const t of items) {
for (const iface of factIfaceAliases(t.iface, t.serverId)) {
const k = `${t.serverId}\0${iface}`
if (seen.has(k)) continue
seen.add(k)
out.push({ serverId: t.serverId, iface })
}
}
return out
}
async function loadPayloadScope(serverId?: number): Promise<{
overlayIfaces: Array<{ serverId: number; iface: string }>
wanIfaces: Array<{ serverId: number; iface: string }>
excludeServerIds: number[]
topo: FlowTopology
}> {
const topo = await loadFlowTopology()
const catalog = await getServerCatalog()
await warmIfaceCache(catalog.list.map((s) => s.id))
const overlayRaw: Array<{ serverId: number; iface: string }> = []
const wanRaw: Array<{ serverId: number; iface: string }> = []
for (const s of catalog.list) {
if (serverId != null && s.id !== serverId) continue
const wanSet = topo.wanIfaces.get(s.id)
const wanNames = wanSet && wanSet.size > 0
? [...wanSet]
: s.type === "home-router" ? [] : ["ether1"]
for (const name of wanNames) wanRaw.push({ serverId: s.id, iface: name })
const names = new Set(listCachedIfaceNames(s.id))
for (const name of topo.tunnelIfaces?.get(s.id) ?? []) names.add(name)
for (const name of names) {
if (isOverlayTunnelIface(topo, s.id, name)) overlayRaw.push({ serverId: s.id, iface: name })
}
}
return {
overlayIfaces: expandIfaceTuples(overlayRaw),
wanIfaces: expandIfaceTuples(wanRaw),
excludeServerIds: serverId != null
? []
: catalog.list.filter((s) => s.type === "exit-node").map((s) => s.id),
topo,
}
}
async function buildFilterCtx(query: StatisticsQuery, period: ParsedPeriod): Promise<FilterCtx | null> { async function buildFilterCtx(query: StatisticsQuery, period: ParsedPeriod): Promise<FilterCtx | null> {
const bindTuples = await loadBindUserTuples() const bindTuples = await loadBindUserTuples()
const boundIfaces = uniqueBoundIfaces(bindTuples) const boundIfaces = uniqueBoundIfaces(bindTuples)
const unboundOnly = query.userId === STATISTICS_UNBOUND_USER_ID const unboundOnly = query.userId === STATISTICS_UNBOUND_USER_ID
const userIfaces = unboundOnly ? null : await resolveUserIfaces(query.userId) const userIfaces = unboundOnly ? null : await resolveUserIfaces(query.userId)
if (userIfaces && userIfaces.length === 0) return null if (userIfaces && userIfaces.length === 0) return null
if (unboundOnly) return null
const scope = await loadPayloadScope(query.serverId)
return { return {
...period, ...period,
serverId: query.serverId, serverId: query.serverId,
@@ -264,9 +382,14 @@ async function buildFilterCtx(query: StatisticsQuery, period: ParsedPeriod): Pro
country: query.country, country: query.country,
service: query.service, service: query.service,
asn: query.asn, asn: query.asn,
planes: query.planes ?? "unique",
userIfaces, userIfaces,
unboundOnly, unboundOnly,
boundIfaces, boundIfaces,
overlayIfaces: scope.overlayIfaces,
wanIfaces: scope.wanIfaces,
excludeServerIds: scope.excludeServerIds,
topo: scope.topo,
} }
} }
@@ -281,6 +404,8 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
windowSec: 1, windowSec: 1,
}) })
await warmBindingIfaceCache()
if (query.serverId) await warmIfaceCache([query.serverId])
const bindTuples = await loadBindUserTuples() const bindTuples = await loadBindUserTuples()
const ctx = await buildFilterCtx(query, period) const ctx = await buildFilterCtx(query, period)
if (!ctx) return emptyDto(period) if (!ctx) return emptyDto(period)
@@ -289,12 +414,11 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
const timeCol = period.grain === "hour" ? "bucket_at" : "day" const timeCol = period.grain === "hour" ? "bucket_at" : "day"
const where = factWhere("f", period.grain, ctx) const where = factWhere("f", period.grain, ctx)
const totals = await dbAll<{ bytes: number; packets: number; servers: number; ifaces: number }>(` const totals = await dbAll<{ bytes: number; packets: number; servers: number }>(`
SELECT SELECT
COALESCE(SUM(f.bytes), 0) AS bytes, COALESCE(SUM(f.bytes), 0) AS bytes,
COALESCE(SUM(f.packets), 0) AS packets, COALESCE(SUM(f.packets), 0) AS packets,
COUNT(DISTINCT f.server_id)::int AS servers, COUNT(DISTINCT f.server_id)::int AS servers
COUNT(DISTINCT (f.server_id::text || ':' || f.iface))::int AS ifaces
FROM ${table} f FROM ${table} f
WHERE ${where.sql} WHERE ${where.sql}
`, where.params) `, where.params)
@@ -302,7 +426,6 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
const bytes = Number(totals[0]?.bytes) || 0 const bytes = Number(totals[0]?.bytes) || 0
const packets = Number(totals[0]?.packets) || 0 const packets = Number(totals[0]?.packets) || 0
const serverCount = Number(totals[0]?.servers) || 0 const serverCount = Number(totals[0]?.servers) || 0
const ifaceCount = Number(totals[0]?.ifaces) || 0
const seriesRows = await dbAll<{ t: string; bytes: number }>(` const seriesRows = await dbAll<{ t: string; bytes: number }>(`
SELECT ${timeCol}::text AS t, SUM(f.bytes) AS bytes SELECT ${timeCol}::text AS t, SUM(f.bytes) AS bytes
@@ -340,12 +463,60 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
GROUP BY f.server_id GROUP BY f.server_id
`, where.params) `, where.params)
const ifaceRows = await dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(` const ifaceRowsRaw = await dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(`
SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f FROM ${table} f
WHERE ${where.sql} WHERE ${where.sql}
GROUP BY f.server_id, f.iface GROUP BY f.server_id, f.iface
`, where.params) `, where.params)
await warmIfaceCache(ifaceRowsRaw.filter((r) => looksLikeIfIndex(r.iface)).map((r) => r.serverId))
const ifaceRows = collapseServerIfaceRows(ifaceRowsRaw).filter((r) => {
if (isJunkFactIface(r.iface) || isDashDisplayIface(r.iface)) return false
if (ctx.iface) return true
if (ctx.topo && isOverlayTunnelIface(ctx.topo, r.serverId, r.iface)) return false
if (ctx.topo && isWanFactIface(ctx.topo, r.serverId, r.iface)) return false
return true
})
const ifaceCount = ifaceRows.length
let dupeIfaceRows: Array<{ serverId: number; iface: string; bytes: number; packets: number; kind: "wan" | "overlay" }> = []
if (ctx.planes === "all" && !ctx.iface) {
const wanWhere = factWhere("f", period.grain, ctx, "wan")
const overlayWhere = factWhere("f", period.grain, ctx, "overlay")
const [wanRaw, overlayRaw] = await Promise.all([
dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(`
SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${wanWhere.sql}
GROUP BY f.server_id, f.iface
`, wanWhere.params),
dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(`
SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${overlayWhere.sql}
GROUP BY f.server_id, f.iface
`, overlayWhere.params),
])
await warmIfaceCache([
...wanRaw.filter((r) => looksLikeIfIndex(r.iface)).map((r) => r.serverId),
...overlayRaw.filter((r) => looksLikeIfIndex(r.iface)).map((r) => r.serverId),
])
const seen = new Set(ifaceRows.map((r) => `${r.serverId}:${r.iface}`))
for (const r of collapseServerIfaceRows(wanRaw)) {
if (isJunkFactIface(r.iface) || isDashDisplayIface(r.iface)) continue
const key = `${r.serverId}:${r.iface}`
if (seen.has(key)) continue
seen.add(key)
dupeIfaceRows.push({ ...r, kind: "wan" })
}
for (const r of collapseServerIfaceRows(overlayRaw)) {
if (isJunkFactIface(r.iface) || isDashDisplayIface(r.iface)) continue
const key = `${r.serverId}:${r.iface}`
if (seen.has(key)) continue
seen.add(key)
dupeIfaceRows.push({ ...r, kind: "overlay" })
}
}
let userRows: Array<{ id: string; bytes: number; packets: number }> = [] let userRows: Array<{ id: string; bytes: number; packets: number }> = []
if (bindTuples.length && !ctx.unboundOnly) { if (bindTuples.length && !ctx.unboundOnly) {
@@ -415,16 +586,34 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
bytes, bytes,
period.windowSec, period.windowSec,
) )
const interfaces = toBreakdown( const uniqueInterfaces = toBreakdown(
ifaceRows.map((r) => ({ ifaceRows.map((r) => {
id: `${r.serverId}:${r.iface}`, const serverName = serverNames.get(r.serverId) || String(r.serverId)
label: `${serverNames.get(r.serverId) || r.serverId} · ${r.iface}`, const wan = ctx.topo ? isWanFactIface(ctx.topo, r.serverId, r.iface) : false
bytes: Number(r.bytes) || 0, return {
packets: Number(r.packets) || 0, id: `${r.serverId}:${r.iface}`,
})), label: wan ? wanIfaceLabel(serverName, r.iface) : `${serverName} · ${r.iface}`,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
}
}),
bytes, bytes,
period.windowSec, period.windowSec,
) )
const dupeInterfaces: StatisticsBreakdownRow[] = dupeIfaceRows.map((r) => {
const serverName = serverNames.get(r.serverId) || String(r.serverId)
const rowBytes = Number(r.bytes) || 0
const rowPackets = Number(r.packets) || 0
return {
id: `${r.serverId}:${r.iface}`,
label: r.kind === "wan" ? wanIfaceLabel(serverName, r.iface) : overlayDupLabel(serverName, r.iface),
bytes: rowBytes,
packets: rowPackets,
bps: (rowBytes * 8) / period.windowSec,
percent: 0,
}
})
const interfaces = [...uniqueInterfaces, ...dupeInterfaces]
const matchedUsers = toBreakdown( const matchedUsers = toBreakdown(
userRows.map((r) => ({ userRows.map((r) => ({
id: r.id, id: r.id,
@@ -437,38 +626,6 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
) )
const users = [...matchedUsers] const users = [...matchedUsers]
if (!ctx.unboundOnly && !ctx.userIfaces) {
let unboundBytes = 0
let unboundPackets = 0
if (ctx.boundIfaces.length === 0) {
unboundBytes = bytes
unboundPackets = packets
} else {
const tuples = ctx.boundIfaces.map(() => "(?, ?)").join(", ")
const unboundParams = [...where.params]
for (const u of ctx.boundIfaces) unboundParams.push(u.serverId, u.iface)
const unboundRows = await dbAll<{ bytes: number; packets: number }>(`
SELECT COALESCE(SUM(f.bytes), 0) AS bytes, COALESCE(SUM(f.packets), 0) AS packets
FROM ${table} f
WHERE ${where.sql}
AND (f.server_id, f.iface) NOT IN (${tuples})
`, unboundParams)
unboundBytes = Number(unboundRows[0]?.bytes) || 0
unboundPackets = Number(unboundRows[0]?.packets) || 0
}
if (unboundBytes > 0) {
const denom = bytes || 1
users.push({
id: STATISTICS_UNBOUND_USER_ID,
label: "Без привязки",
bytes: unboundBytes,
packets: unboundPackets,
bps: (unboundBytes * 8) / period.windowSec,
percent: (unboundBytes / denom) * 100,
})
users.sort((a, b) => b.bytes - a.bytes)
}
}
return { return {
from: period.fromIso, from: period.fromIso,
@@ -522,6 +679,8 @@ export async function getStatisticsPivot(query: StatisticsPivotQuery): Promise<S
if (pivotDimsConflict(query.row, query.col)) return emptyPivot(query) if (pivotDimsConflict(query.row, query.col)) return emptyPivot(query)
const period = parseStatisticsPeriod(query.from, query.to) const period = parseStatisticsPeriod(query.from, query.to)
if (!period) return emptyPivot(query) if (!period) return emptyPivot(query)
await warmBindingIfaceCache()
if (query.serverId) await warmIfaceCache([query.serverId])
const bindTuples = await loadBindUserTuples() const bindTuples = await loadBindUserTuples()
const ctx = await buildFilterCtx(query, period) const ctx = await buildFilterCtx(query, period)
if (!ctx) return emptyPivot(query) if (!ctx) return emptyPivot(query)
@@ -543,6 +702,25 @@ export async function getStatisticsPivot(query: StatisticsPivotQuery): Promise<S
GROUP BY 1, 2 GROUP BY 1, 2
`, [...join.params, ...where.params]) `, [...join.params, ...where.params])
if (query.row === "iface" || query.col === "iface") {
const ifaceServerIds: number[] = []
for (const r of raw) {
for (const dim of [query.row, query.col] as const) {
if (dim !== "iface") continue
const id = dim === query.row ? String(r.row_id ?? "") : String(r.col_id ?? "")
const colon = id.indexOf(":")
if (colon < 0) continue
const sid = Number(id.slice(0, colon))
if (looksLikeIfIndex(id.slice(colon + 1)) && Number.isFinite(sid)) ifaceServerIds.push(sid)
}
}
await warmIfaceCache(ifaceServerIds)
for (const r of raw) {
if (query.row === "iface") r.row_id = canonicalIfaceDimId(String(r.row_id ?? ""))
if (query.col === "iface") r.col_id = canonicalIfaceDimId(String(r.col_id ?? ""))
}
}
const metric = query.metric const metric = query.metric
type Acc = { bytes: number; packets: number } type Acc = { bytes: number; packets: number }
const cell = new Map<string, Map<string, Acc>>() const cell = new Map<string, Map<string, Acc>>()
@@ -659,6 +837,7 @@ async function loadPivotLabels(
rowIds: string[], rowIds: string[],
colIds: string[], colIds: string[],
): Promise<{ row: Map<string, string>; col: Map<string, string> }> { ): Promise<{ row: Map<string, string>; col: Map<string, string> }> {
const topo = await loadFlowTopology()
const serverNames = new Map<string, string>() const serverNames = new Map<string, string>()
const allServers = await db.select({ id: servers.id, name: servers.name, host: servers.host }).from(servers) const allServers = await db.select({ id: servers.id, name: servers.name, host: servers.host }).from(servers)
for (const s of allServers) serverNames.set(String(s.id), s.name || s.host) for (const s of allServers) serverNames.set(String(s.id), s.name || s.host)
@@ -684,7 +863,16 @@ async function loadPivotLabels(
if (colon < 0) return id if (colon < 0) return id
const sid = id.slice(0, colon) const sid = id.slice(0, colon)
const iface = id.slice(colon + 1) const iface = id.slice(colon + 1)
return `${serverNames.get(sid) || sid} · ${iface}` const sidNum = Number(sid)
const name = Number.isFinite(sidNum) ? displayFactIface(sidNum, iface) : iface
const serverName = serverNames.get(sid) || sid
if (Number.isFinite(sidNum) && isWanFactIface(topo, sidNum, name)) {
return wanIfaceLabel(serverName, name)
}
if (Number.isFinite(sidNum) && isOverlayTunnelIface(topo, sidNum, name)) {
return overlayDupLabel(serverName, name)
}
return `${serverName} · ${name}`
} }
return id return id
} }
+20 -9
View File
@@ -27,11 +27,9 @@ import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-setting
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js" import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js" import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
import { enqueueRipeMisses } from "./traffic-flow-ripe.js" import { enqueueRipeMisses } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js" import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js" import { resolveInternetDest } from "./traffic-flow-dest.js"
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { import {
enGreIfaceNames, enGreIfaceNames,
getServerCatalog, getServerCatalog,
@@ -253,11 +251,24 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
totalPackets += r.packets totalPackets += r.packets
srcs.add(r.src) srcs.add(r.src)
dsts.add(r.dst) dsts.add(r.dst)
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort) const destMeta = resolveInternetDest({
peers.add(peer) src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
serverId: r.serverId,
inIface: resolved.name,
topo,
natSrc: r.natSrc,
natDst: r.natDst,
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
})
if (destMeta.dest) peers.add(destMeta.dest)
const app = applicationName(r.proto, r.dstPort, r.srcPort) const app = applicationName(r.proto, r.dstPort, r.srcPort)
const ripe = resolveFlowIp(peer) const ripe = destMeta.ripe
const classified = classifyFlowDst(peer, r.proto, r.dstPort, r.srcPort, ripe) const classified = destMeta.classified
bump(applications, app, r.bytes, r.packets) bump(applications, app, r.bytes, r.packets)
bump(protocols, protoName(r.proto), r.bytes, r.packets) bump(protocols, protoName(r.proto), r.bytes, r.packets)
bump(sources, r.src, r.bytes, r.packets) bump(sources, r.src, r.bytes, r.packets)
@@ -269,7 +280,7 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
const asnLabel = ripe.holder ? `AS${ripe.asn} ${ripe.holder}` : `AS${ripe.asn}` const asnLabel = ripe.holder ? `AS${ripe.asn} ${ripe.holder}` : `AS${ripe.asn}`
bump(asns, asnId, r.bytes, r.packets, asnLabel) bump(asns, asnId, r.bytes, r.packets, asnLabel)
} }
const dstCountry = ripe?.ok && isIsoCountry(ripe.country) ? ripe.country : "" const dstCountry = destMeta.country && destMeta.country !== "unknown" ? destMeta.country : ""
if (dstCountry) { if (dstCountry) {
bump(countries, dstCountry, r.bytes, r.packets) bump(countries, dstCountry, r.bytes, r.packets)
} }
@@ -121,6 +121,17 @@ assert.equal(googleCloud.category, "Веб")
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null) const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
assert.equal(gre.service, "GRE") assert.equal(gre.service, "GRE")
assert.equal(gre.category, "Туннель") assert.equal(gre.category, "Туннель")
const greIgnore = classifyFlowDst("8.8.8.8", 47, 0, 0, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
}, { ignoreTunnelProto: true })
assert.equal(greIgnore.service, "Google")
const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null) const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null)
assert.equal(esp.category, "Туннель") assert.equal(esp.category, "Туннель")
assert.equal(applicationName(17, 443, 50000), "QUIC") assert.equal(applicationName(17, 443, 50000), "QUIC")
@@ -73,9 +73,12 @@ export function classifyFlowDst(
dstPort: number, dstPort: number,
srcPort: number, srcPort: number,
ripe: FlowIpMeta | null, ripe: FlowIpMeta | null,
opts?: { ignoreTunnelProto?: boolean },
): FlowClassification { ): FlowClassification {
if (proto === 47) return { service: "GRE", category: "Туннель" } if (!opts?.ignoreTunnelProto) {
if (proto === 50) return { service: "ESP", category: "Туннель" } if (proto === 47) return { service: "GRE", category: "Туннель" }
if (proto === 50) return { service: "ESP", category: "Туннель" }
}
const app = applicationName(proto, dstPort, srcPort) const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" } if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
const hit = matchCidr(dst) const hit = matchCidr(dst)
@@ -0,0 +1,144 @@
import assert from "node:assert/strict"
import {
ingestParsedFlowsForServerForTests,
resetEngineForTests,
} from "./traffic-flow-engine.js"
import { factsSnapshotForTests } from "./traffic-flow-facts.js"
import { classifyInternetBrand } from "./traffic-flow-dest.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
disableRipeEnqueueForTests()
resetRipeCacheForTests()
resetEngineForTests()
resetIfaceCacheForTests()
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "95.167.0.0/16",
asn: 12389,
country: "RU",
lat: null,
lng: null,
holder: "ROSTELECOM-AS",
ok: true,
fetchedAt: Date.now(),
})
const topo: FlowTopology = {
clientIfaces: new Map([[1, new Set(["gre-client"])]]),
clientByIface: new Map([["1|gre-client", {
userId: "u-rost",
login: "alice",
name: "Alice",
serverId: 1,
interfaceName: "gre-client",
}]]),
enNodes: [{ id: 2, name: "en", hosts: ["198.51.100.1"] }],
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
wanIfaces: new Map([[1, new Set(["ether1"])]]),
plane: {
clientIfaceNames: new Set(["gre-client"]),
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
},
}
seedFlowTopologyForTests(topo)
rememberServerIfaces(1, [{ name: "gre-client", ifindex: "2" }])
ingestParsedFlowsForServerForTests(1, [
{
src: "95.167.1.10",
dst: "10.200.100.53",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 100,
packets: 2,
inIface: "gre-client",
outIface: "ether1",
},
{
src: "95.167.1.10",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 50,
packets: 1,
inIface: "gre-client",
outIface: "ether1",
},
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 9_000,
packets: 90,
inIface: "NSK-SERVHOST-RTK",
outIface: "NSK-SERVHOST-RTK",
},
{
src: "10.200.100.53",
dst: "10.200.100.1",
proto: 6,
srcPort: 53880,
dstPort: 443,
bytes: 70,
packets: 1,
inIface: "gre-client",
outIface: "ether1",
natDst: "8.8.8.8",
natDstPort: 443,
},
])
const facts = factsSnapshotForTests()
const total = facts.reduce((s, r) => s + r.bytes, 0)
assert.equal(total, 120, "unique = Google payload + NAT, без overlay/пустого dest")
assert.equal(facts.some((r) => r.asn === 12389), false, "ASN клиента не в кубе")
assert.equal(facts.some((r) => r.service === "GRE"), false, "GRE не сервис unique")
const google = facts.find((r) => r.asn === 15169)
assert.ok(google)
assert.equal(google.bytes, 120)
assert.equal(facts.filter((r) => r.asn === 0).reduce((s, r) => s + r.bytes, 0), 0)
assert.equal(classifyInternetBrand("8.8.8.8", 47, 0, 0, null), null, "GRE не бренд")
assert.equal(classifyInternetBrand("8.8.8.8", 6, 443, 51234, {
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})?.service, "Google")
resetEngineForTests()
seedFlowTopologyForTests(null)
resetRipeCacheForTests()
resetIfaceCacheForTests()
console.log("traffic-flow-dest.test.ts: ok")
+98
View File
@@ -0,0 +1,98 @@
import { applicationName } from "./traffic-flow-apps.js"
import { isIsoCountry, isNamedInternetService, resolveFlowBrand } from "./traffic-flow-brands.js"
import { classifyFlowDst, type FlowClassification } from "./traffic-flow-classify.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { pickInternetDest, type InternetDestCtx } from "./traffic-flow-ip.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import {
flowOursHosts,
resolveClient,
type FlowTopology,
} from "./traffic-flow-topology.js"
export interface InternetDestMeta {
dest: string
ripe: FlowIpMeta | null
classified: FlowClassification
country: string
asn: number
}
export function destCtxForIface(
topo: FlowTopology | null | undefined,
serverId: number,
inIface: string,
nat?: Pick<InternetDestCtx, "natSrc" | "natDst" | "natSrcPort" | "natDstPort">,
): InternetDestCtx {
const name = canonicalFactIface(serverId, inIface) || String(inIface ?? "").trim()
return {
ours: flowOursHosts(topo),
boundClient: Boolean(
topo && name && (
resolveClient(topo, serverId, name)
|| topo.clientIfaces.get(serverId)?.has(name)
),
),
natSrc: nat?.natSrc,
natDst: nat?.natDst,
natSrcPort: nat?.natSrcPort,
natDstPort: nat?.natDstPort,
}
}
/** Бренд интернет-dest как на карте: GRE/ESP/WG — транспорт, не сервис. */
export function classifyInternetBrand(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): FlowClassification | null {
if (proto === 47 || proto === 50) return null
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
export function resolveInternetDest(opts: {
src: string
dst: string
proto: number
srcPort: number
dstPort: number
serverId: number
inIface: string
topo?: FlowTopology | null
natSrc?: string
natDst?: string
natSrcPort?: number
natDstPort?: number
}): InternetDestMeta {
const dest = pickInternetDest(
opts.src,
opts.dst,
opts.srcPort,
opts.dstPort,
destCtxForIface(opts.topo, opts.serverId, opts.inIface, {
natSrc: opts.natSrc,
natDst: opts.natDst,
natSrcPort: opts.natSrcPort,
natDstPort: opts.natDstPort,
}),
)
const ripe = dest ? resolveFlowIp(dest) : null
const classified = dest
? classifyFlowDst(dest, opts.proto, opts.dstPort, opts.srcPort, ripe, { ignoreTunnelProto: true })
: classifyFlowDst(opts.dst, opts.proto, opts.dstPort, opts.srcPort, ripe)
if (!dest) {
return { dest: "", ripe: null, classified, country: "", asn: 0 }
}
const country = ripe?.ok && isIsoCountry(ripe.country)
? ripe.country
: (ripe?.ok ? "" : "unknown")
const asn = ripe?.ok && ripe.asn ? ripe.asn : 0
return { dest, ripe, classified, country, asn }
}
+131 -35
View File
@@ -4,14 +4,18 @@ import { normalizeParsedFlow, parseFlowPacket, protoName, type ParsedFlow, type
import { classifyFlowPlaneLite } from "./traffic-flow-planes.js" import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js" import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
import { applicationName } from "./traffic-flow-apps.js" import { applicationName } from "./traffic-flow-apps.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { enqueueRipeMisses, pruneRipeSqlite } from "./traffic-flow-ripe.js" import { enqueueRipeMisses, pruneRipeSqlite } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js" import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js" import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js" import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { pickInternetPeer } from "./traffic-flow-ip.js" import { shouldWriteFlowFact } from "./traffic-flow-facts-filter.js"
import { resolveInternetDest } from "./traffic-flow-dest.js"
import {
getServerCatalog,
loadFlowTopology,
peekFlowTopology,
peekServerCatalog,
} from "./traffic-flow-topology.js"
import { import {
bumpFlowFact, bumpFlowFact,
factsPendingSize, factsPendingSize,
@@ -57,6 +61,10 @@ export interface PendingFlowRow {
nextHop: string nextHop: string
flowStartMs: number flowStartMs: number
flowEndMs: number flowEndMs: number
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
} }
function inetOrNull(value: string | null | undefined): string | null { function inetOrNull(value: string | null | undefined): string | null {
@@ -86,6 +94,17 @@ function clampProto(n: number): number {
return Math.max(0, Math.min(255, Math.trunc(n))) return Math.max(0, Math.min(255, Math.trunc(n)))
} }
function clampPort(n: number): number {
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.min(65535, Math.trunc(n)))
}
function sanitizeNatIp(value: string | null | undefined): string {
const s = String(value ?? "").trim()
if (!s || s === "0.0.0.0") return ""
return isValidFlowInet(s) ? s : ""
}
function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null { function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
const src = (r.src || "").trim() || "0.0.0.0" const src = (r.src || "").trim() || "0.0.0.0"
const dst = (r.dst || "").trim() || "0.0.0.0" const dst = (r.dst || "").trim() || "0.0.0.0"
@@ -97,6 +116,10 @@ function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
dst, dst,
nextHop: next && isValidFlowInet(next) ? next : "", nextHop: next && isValidFlowInet(next) ? next : "",
proto: clampProto(r.proto), proto: clampProto(r.proto),
natSrc: sanitizeNatIp(r.natSrc),
natDst: sanitizeNatIp(r.natDst),
natSrcPort: clampPort(r.natSrcPort),
natDstPort: clampPort(r.natDstPort),
} }
} }
@@ -116,6 +139,10 @@ function flowUpsertParams(r: PendingFlowRow) {
nextHop: inetOrNull(r.nextHop), nextHop: inetOrNull(r.nextHop),
flowStartMs: r.flowStartMs, flowStartMs: r.flowStartMs,
flowEndMs: r.flowEndMs, flowEndMs: r.flowEndMs,
natSrc: inetOrNull(r.natSrc),
natDst: inetOrNull(r.natDst),
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
} }
} }
@@ -338,19 +365,35 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
const bucketAt = minuteBucketIso() const bucketAt = minuteBucketIso()
const hourAt = hourBucketIso() const hourAt = hourBucketIso()
const ripeMisses: string[] = [] const ripeMisses: string[] = []
const topo = peekFlowTopology()
const catalog = peekServerCatalog()
if (!topo) void loadFlowTopology().catch(() => {})
if (!catalog) void getServerCatalog().catch(() => {})
const serverType = catalog?.byId.get(serverId)?.type
for (const raw of flows) { for (const raw of flows) {
const flow = normalizeParsedFlow(raw) const flow = normalizeParsedFlow(raw)
addToTick(serverId, flow, flow.bytes) addToTick(serverId, flow, flow.bytes)
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets) bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
const peer = pickInternetPeer(flow.src, flow.dst, flow.srcPort, flow.dstPort) const destMeta = resolveInternetDest({
const ripe = resolveFlowIp(peer) src: flow.src,
if (peer && !ripe) ripeMisses.push(peer) dst: flow.dst,
const classified = classifyFlowDst(peer, flow.proto, flow.dstPort, flow.srcPort, ripe) proto: flow.proto,
srcPort: flow.srcPort,
dstPort: flow.dstPort,
serverId,
inIface: flow.inIface,
topo,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
})
const ripe = destMeta.ripe
if (destMeta.dest && !ripe) ripeMisses.push(destMeta.dest)
const classified = destMeta.classified
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort) const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
const country = ripe?.ok && isIsoCountry(ripe.country) const country = destMeta.country
? ripe.country const asnKey = destMeta.asn ? String(destMeta.asn) : "unknown"
: (ripe?.ok ? "" : "unknown")
const asnKey = ripe?.ok && ripe.asn ? String(ripe.asn) : "unknown"
bumpDim(serverId, bucketAt, "proto", protoName(flow.proto), flow.bytes, flow.packets) bumpDim(serverId, bucketAt, "proto", protoName(flow.proto), flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "app", app, flow.bytes, flow.packets) bumpDim(serverId, bucketAt, "app", app, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "iface", flow.inIface || "__unknown__", flow.bytes, flow.packets) bumpDim(serverId, bucketAt, "iface", flow.inIface || "__unknown__", flow.bytes, flow.packets)
@@ -358,16 +401,34 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
bumpDim(serverId, bucketAt, "service", classified.service, flow.bytes, flow.packets) bumpDim(serverId, bucketAt, "service", classified.service, flow.bytes, flow.packets)
if (country) bumpDim(serverId, bucketAt, "country", country, flow.bytes, flow.packets) if (country) bumpDim(serverId, bucketAt, "country", country, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "asn", asnKey, flow.bytes, flow.packets) bumpDim(serverId, bucketAt, "asn", asnKey, flow.bytes, flow.packets)
bumpFlowFact({ if (shouldWriteFlowFact({
serverId, serverId,
bucketAt: hourAt, serverType,
iface: canonicalFactIface(serverId, flow.inIface), inIface: flow.inIface,
country: country || "XX", outIface: flow.outIface,
service: classified.service, proto: flow.proto,
asn: ripe?.ok && ripe.asn ? ripe.asn : 0, srcPort: flow.srcPort,
bytes: flow.bytes, dstPort: flow.dstPort,
packets: flow.packets, src: flow.src,
}) dst: flow.dst,
topo,
dest: destMeta.dest,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
})) {
bumpFlowFact({
serverId,
bucketAt: hourAt,
iface: canonicalFactIface(serverId, flow.inIface),
country: country || "XX",
service: classified.service,
asn: destMeta.asn,
bytes: flow.bytes,
packets: flow.packets,
})
}
const key = pendingKey(serverId, bucketAt, flow) const key = pendingKey(serverId, bucketAt, flow)
const prev = pending.get(key) const prev = pending.get(key)
@@ -376,6 +437,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
prev.packets += flow.packets prev.packets += flow.packets
if (flow.outIface && !prev.flow.outIface) prev.flow.outIface = flow.outIface if (flow.outIface && !prev.flow.outIface) prev.flow.outIface = flow.outIface
if (flow.nextHop && !prev.flow.nextHop) prev.flow.nextHop = flow.nextHop if (flow.nextHop && !prev.flow.nextHop) prev.flow.nextHop = flow.nextHop
if (flow.natSrc && !prev.flow.natSrc) prev.flow.natSrc = flow.natSrc
if (flow.natDst && !prev.flow.natDst) prev.flow.natDst = flow.natDst
if (flow.natSrcPort && !prev.flow.natSrcPort) prev.flow.natSrcPort = flow.natSrcPort
if (flow.natDstPort && !prev.flow.natDstPort) prev.flow.natDstPort = flow.natDstPort
if (flow.flowStartMs && (!prev.flow.flowStartMs || flow.flowStartMs < prev.flow.flowStartMs)) { if (flow.flowStartMs && (!prev.flow.flowStartMs || flow.flowStartMs < prev.flow.flowStartMs)) {
prev.flow.flowStartMs = flow.flowStartMs prev.flow.flowStartMs = flow.flowStartMs
} }
@@ -431,6 +496,10 @@ function toPendingRow(row: PendingEntry): PendingFlowRow {
nextHop: flow.nextHop, nextHop: flow.nextHop,
flowStartMs: flow.flowStartMs, flowStartMs: flow.flowStartMs,
flowEndMs: flow.flowEndMs, flowEndMs: flow.flowEndMs,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
} }
} }
@@ -442,6 +511,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
prev.packets += row.packets prev.packets += row.packets
if (row.outIface && !prev.outIface) prev.outIface = row.outIface if (row.outIface && !prev.outIface) prev.outIface = row.outIface
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
if (row.natSrc && !prev.natSrc) prev.natSrc = row.natSrc
if (row.natDst && !prev.natDst) prev.natDst = row.natDst
if (row.natSrcPort && !prev.natSrcPort) prev.natSrcPort = row.natSrcPort
if (row.natDstPort && !prev.natDstPort) prev.natDstPort = row.natDstPort
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
return return
@@ -750,7 +823,7 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
await pool.query({ await pool.query({
text: ` text: `
INSERT INTO flow_buckets ( INSERT INTO flow_buckets (
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms, nat_src, nat_dst, nat_src_port, nat_dst_port
) )
SELECT * SELECT *
FROM UNNEST( FROM UNNEST(
@@ -767,8 +840,12 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
$11::text[], $11::text[],
$12::inet[], $12::inet[],
$13::bigint[], $13::bigint[],
$14::bigint[] $14::bigint[],
) AS t(server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms) $15::inet[],
$16::inet[],
$17::int[],
$18::int[]
) AS t(server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms, nat_src, nat_dst, nat_src_port, nat_dst_port)
ON CONFLICT (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface) ON CONFLICT (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET DO UPDATE SET
bytes = flow_buckets.bytes + excluded.bytes, bytes = flow_buckets.bytes + excluded.bytes,
@@ -778,7 +855,11 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
flow_start_ms = CASE flow_start_ms = CASE
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms) WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END, THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms) flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms),
nat_src = COALESCE(excluded.nat_src, flow_buckets.nat_src),
nat_dst = COALESCE(excluded.nat_dst, flow_buckets.nat_dst),
nat_src_port = CASE WHEN excluded.nat_src_port > 0 THEN excluded.nat_src_port ELSE flow_buckets.nat_src_port END,
nat_dst_port = CASE WHEN excluded.nat_dst_port > 0 THEN excluded.nat_dst_port ELSE flow_buckets.nat_dst_port END
`, `,
values: [ values: [
rows.map((r) => r.serverId), rows.map((r) => r.serverId),
@@ -795,15 +876,19 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
rows.map((r) => inetOrNull(r.nextHop)), rows.map((r) => inetOrNull(r.nextHop)),
rows.map((r) => r.flowStartMs), rows.map((r) => r.flowStartMs),
rows.map((r) => r.flowEndMs), rows.map((r) => r.flowEndMs),
rows.map((r) => inetOrNull(r.natSrc)),
rows.map((r) => inetOrNull(r.natDst)),
rows.map((r) => r.natSrcPort),
rows.map((r) => r.natDstPort),
], ],
}) })
} }
const FLOW_UPSERT_SQL = ` const FLOW_UPSERT_SQL = `
INSERT INTO flow_buckets ( INSERT INTO flow_buckets (
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms, nat_src, nat_dst, nat_src_port, nat_dst_port
) VALUES ( ) VALUES (
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs @serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs, @natSrc, @natDst, @natSrcPort, @natDstPort
) )
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface) ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET DO UPDATE SET
@@ -814,7 +899,11 @@ const FLOW_UPSERT_SQL = `
flow_start_ms = CASE flow_start_ms = CASE
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms) WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END, THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms) flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms),
nat_src = COALESCE(excluded.nat_src, flow_buckets.nat_src),
nat_dst = COALESCE(excluded.nat_dst, flow_buckets.nat_dst),
nat_src_port = CASE WHEN excluded.nat_src_port > 0 THEN excluded.nat_src_port ELSE flow_buckets.nat_src_port END,
nat_dst_port = CASE WHEN excluded.nat_dst_port > 0 THEN excluded.nat_dst_port ELSE flow_buckets.nat_dst_port END
` `
async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> { async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> {
@@ -842,10 +931,11 @@ async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> {
} }
} }
export async function flushPending(opts?: { force?: boolean }): Promise<void> { export async function flushPending(opts?: { force?: boolean; prune?: boolean }): Promise<void> {
pruneRecent() pruneRecent()
rollFlowRings() rollFlowRings()
const force = Boolean(opts?.force) const force = Boolean(opts?.force)
const doPrune = opts?.prune !== false
const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0 || factsPendingSize() > 0 const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0 || factsPendingSize() > 0
const due = persistDue(force, hasWork) const due = persistDue(force, hasWork)
try { try {
@@ -855,7 +945,7 @@ export async function flushPending(opts?: { force?: boolean }): Promise<void> {
} }
if (!hasWork) { if (!hasWork) {
if (force) { if (force && doPrune) {
try { try {
await pruneStored() await pruneStored()
} catch { } catch {
@@ -909,10 +999,12 @@ export async function flushPending(opts?: { force?: boolean }): Promise<void> {
} catch { } catch {
/* statistics cube best-effort */ /* statistics cube best-effort */
} }
try { if (doPrune) {
await pruneStored() try {
} catch { await pruneStored()
/* prune best-effort */ } catch {
/* prune best-effort */
}
} }
} }
@@ -920,8 +1012,12 @@ export function lastFlushUsedTransactionForTests(): boolean {
return lastFlushUsedTransaction return lastFlushUsedTransaction
} }
export async function flushEngineNow(opts?: { prune?: boolean }): Promise<void> {
await flushPending({ force: true, prune: opts?.prune })
}
export async function flushPendingForTests(): Promise<void> { export async function flushPendingForTests(): Promise<void> {
await flushPending({ force: true }) await flushEngineNow()
} }
export function onEngineTick(): void { export function onEngineTick(): void {
@@ -0,0 +1,207 @@
import assert from "node:assert/strict"
import {
isJunkFactIface,
isOverlayGreIface,
isOverlayTunnelIface,
isWanFactIface,
shouldWriteFlowFact,
} from "./traffic-flow-facts-filter.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
function topo(partial: Partial<FlowTopology> = {}): FlowTopology {
const wanIfaces = partial.wanIfaces ?? new Map([[1, new Set(["ether1"])]])
const clientIfaces = partial.clientIfaces ?? new Map([[1, new Set(["gre-client"])]])
const clientByIface = partial.clientByIface ?? new Map()
const enHosts = partial.enHosts ?? new Set(["198.51.100.1"])
const jhHosts = partial.jhHosts ?? new Set(["203.0.113.10"])
return {
clientIfaces,
clientByIface,
enNodes: partial.enNodes ?? [{ id: 2, name: "en", hosts: ["198.51.100.1"] }],
enHosts,
jhHosts,
wanIfaces,
tunnelIfaces: partial.tunnelIfaces,
plane: partial.plane ?? {
clientIfaceNames: new Set(["gre-client"]),
enHosts,
jhHosts,
},
}
}
resetIfaceCacheForTests()
rememberServerIfaces(1, [{ name: "ether1", ifindex: "2" }])
seedFlowTopologyForTests(topo())
assert.equal(isJunkFactIface("0"), true)
assert.equal(isJunkFactIface(""), true)
assert.equal(isJunkFactIface("wg-flow"), true)
assert.equal(isJunkFactIface("ether1"), false)
assert.equal(isWanFactIface(topo(), 1, "ether1"), true)
assert.equal(isOverlayGreIface(topo(), 1, "gre-en"), true)
assert.equal(isOverlayGreIface(topo(), 1, "gre-client"), false)
assert.equal(isOverlayGreIface(topo(), 1, "ether1"), false)
const typed = topo({
clientIfaces: new Map([[1, new Set(["gre-client", "wg-server"])]]),
tunnelIfaces: new Map([[1, new Set(["gre-en", "NSK-SERVHOST-RTK", "wg-jh-en", "wg-server"])]]),
plane: {
clientIfaceNames: new Set(["gre-client", "wg-server"]),
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
},
})
assert.equal(isOverlayTunnelIface(typed, 1, "NSK-SERVHOST-RTK"), true, "кастомное GRE overlay по type")
assert.equal(isOverlayTunnelIface(typed, 1, "wg-jh-en"), true, "WG overlay по type")
assert.equal(isOverlayTunnelIface(typed, 1, "wg-server"), false, "клиентский WG с binding")
assert.equal(isOverlayTunnelIface(typed, 1, "wg-flow"), false, "wg-flow не overlay")
assert.equal(isOverlayTunnelIface(topo(), 1, "NSK-SERVHOST-RTK"), false, "без type в снимке — не overlay")
const overlayOuter = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "ether1",
outIface: "gre-en",
proto: 47,
srcPort: 0,
dstPort: 0,
src: "203.0.113.10",
dst: "198.51.100.1",
topo: topo(),
})
assert.equal(overlayOuter, false, "overlay proto 47 на ether1 не в facts")
const payloadGre = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "gre-client",
outIface: "gre-en",
proto: 6,
srcPort: 51234,
dstPort: 443,
src: "10.100.1.17",
dst: "8.8.8.8",
topo: topo(),
})
assert.equal(payloadGre, true, "payload на GRE — да")
const payloadWan = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "ether1",
outIface: "gre-client",
proto: 6,
srcPort: 443,
dstPort: 51234,
src: "8.8.8.8",
dst: "10.100.1.17",
topo: topo(),
})
assert.equal(payloadWan, true, "payload на ether1 WAN — да")
const junkZero = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "0",
proto: 6,
srcPort: 443,
dstPort: 80,
src: "1.1.1.1",
dst: "8.8.8.8",
topo: topo(),
})
assert.equal(junkZero, false)
const enTopo = topo({
clientIfaces: new Map([[2, new Set()]]),
wanIfaces: new Map([[2, new Set(["ether1"])]]),
})
const enTransit = shouldWriteFlowFact({
serverId: 2,
serverType: "exit-node",
inIface: "gre-jh",
outIface: "ether1",
proto: 6,
srcPort: 51234,
dstPort: 443,
src: "10.100.1.17",
dst: "8.8.8.8",
topo: enTopo,
})
assert.equal(enTransit, false, "EN-транзит без клиента — нет")
const enWan = shouldWriteFlowFact({
serverId: 2,
serverType: "exit-node",
inIface: "ether1",
proto: 6,
srcPort: 443,
dstPort: 80,
src: "8.8.8.8",
dst: "198.51.100.1",
topo: enTopo,
})
assert.equal(enWan, true, "WAN payload на EN — да")
const emptyDest = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "gre-client",
outIface: "ether1",
proto: 6,
srcPort: 51234,
dstPort: 443,
src: "95.167.1.10",
dst: "10.200.100.53",
topo: topo(),
})
assert.equal(emptyDest, false, "пустой интернет-dest не в facts")
const jhToEnHosts = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "ether1",
proto: 6,
srcPort: 0,
dstPort: 0,
src: "203.0.113.10",
dst: "198.51.100.1",
topo: topo(),
})
assert.equal(jhToEnHosts, false, "JH↔EN hosts не dest")
const overlayNamed = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "NSK-SERVHOST-RTK",
outIface: "NSK-SERVHOST-RTK",
proto: 47,
srcPort: 0,
dstPort: 0,
src: "203.0.113.10",
dst: "198.51.100.1",
topo: typed,
})
assert.equal(overlayNamed, false, "overlay proto 47 на NSK-SERVHOST-RTK не в facts")
const natPayload = shouldWriteFlowFact({
serverId: 1,
serverType: "jump-host",
inIface: "gre-client",
outIface: "ether1",
proto: 6,
srcPort: 53880,
dstPort: 443,
src: "10.200.100.53",
dst: "10.200.100.1",
natDst: "8.8.8.8",
natDstPort: 443,
topo: topo(),
})
assert.equal(natPayload, true, "NAT Google на client GRE — да")
seedFlowTopologyForTests(null)
resetIfaceCacheForTests()
console.log("traffic-flow-facts-filter.test.ts: ok")
@@ -0,0 +1,144 @@
import { mapRosInterfaceType } from "../modules/users/iface-type.js"
import { STATISTICS_DUP_MARK, STATISTICS_WAN_MARK } from "@mmapp/contracts/statistics"
import { destCtxForIface } from "./traffic-flow-dest.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { pickInternetDest, isLocalIp } from "./traffic-flow-ip.js"
import { classifyFlowPlane, isTunnelProto } from "./traffic-flow-planes.js"
import { flowOursHosts, resolveClient, type FlowTopology } from "./traffic-flow-topology.js"
const JUNK_IFACE = new Set(["", "0", "—", "__unknown__", "wg-flow"])
export { STATISTICS_WAN_MARK, STATISTICS_DUP_MARK }
export function isJunkFactIface(iface: string | null | undefined): boolean {
const n = String(iface ?? "").trim()
if (JUNK_IFACE.has(n)) return true
return /^#?0$/.test(n)
}
export function isDashDisplayIface(iface: string): boolean {
return String(iface ?? "").trim() === "—"
}
export function isMgmtIface(name: string): boolean {
const n = String(name ?? "").trim().toLowerCase()
return n === "wg-flow" || n.endsWith("/wg-flow") || n.includes("wg-flow")
}
/** GRE или WG по снимку RouterOS, иначе по имени. */
export function isTunnelIfaceName(
topo: FlowTopology | null | undefined,
serverId: number,
iface: string,
): boolean {
const name = String(iface ?? "").trim()
if (!name || isJunkFactIface(name) || isMgmtIface(name)) return false
const typed = topo?.tunnelIfaces?.get(serverId)
if (typed && typed.size > 0) return typed.has(name)
const t = mapRosInterfaceType("", name)
return t === "gre" || t === "wg"
}
/** WAN uplink: `wanIfaces` топологии, иначе ether1 у JH/EN без wan_uplinks. */
export function isWanFactIface(
topo: FlowTopology | null | undefined,
serverId: number,
iface: string,
): boolean {
const name = String(iface ?? "").trim()
if (!name || isJunkFactIface(name) || isDashDisplayIface(name)) return false
const wan = topo?.wanIfaces.get(serverId)
if (wan && wan.size > 0) return wan.has(name)
return /^ether1$/i.test(name)
}
/** Overlay JH↔EN: GRE/WG не клиент, не WAN, не wg-flow. */
export function isOverlayTunnelIface(
topo: FlowTopology | null | undefined,
serverId: number,
iface: string,
): boolean {
const name = String(iface ?? "").trim()
if (!name || isWanFactIface(topo, serverId, name) || isMgmtIface(name)) return false
if (topo?.clientIfaces.get(serverId)?.has(name)) return false
return isTunnelIfaceName(topo, serverId, name)
}
/** @deprecated используйте isOverlayTunnelIface (GRE и WG). */
export function isOverlayGreIface(
topo: FlowTopology | null | undefined,
serverId: number,
iface: string,
): boolean {
return isOverlayTunnelIface(topo, serverId, iface)
}
export function shouldWriteFlowFact(opts: {
serverId: number
serverType?: string
inIface: string
outIface?: string
proto: number
srcPort: number
dstPort: number
src: string
dst: string
topo?: FlowTopology | null
dest?: string
natSrc?: string
natDst?: string
natSrcPort?: number
natDstPort?: number
}): boolean {
const inName = canonicalFactIface(opts.serverId, opts.inIface) || String(opts.inIface ?? "").trim()
if (isJunkFactIface(inName) || isJunkFactIface(opts.inIface)) return false
const outRaw = String(opts.outIface ?? "").trim()
const outName = outRaw ? (canonicalFactIface(opts.serverId, outRaw) || outRaw) : ""
const plane = classifyFlowPlane({
src: opts.src,
dst: opts.dst,
proto: opts.proto,
srcPort: opts.srcPort,
dstPort: opts.dstPort,
inIface: inName,
outIface: outName || undefined,
}, opts.topo?.plane)
if (plane === "mgmt") return false
if (plane === "overlay" || isTunnelProto(opts.proto, opts.srcPort, opts.dstPort)) return false
const dest = opts.dest !== undefined
? opts.dest
: pickInternetDest(
opts.src,
opts.dst,
opts.srcPort,
opts.dstPort,
destCtxForIface(opts.topo, opts.serverId, inName, {
natSrc: opts.natSrc,
natDst: opts.natDst,
natSrcPort: opts.natSrcPort,
natDstPort: opts.natDstPort,
}),
)
if (!dest) return false
const ours = flowOursHosts(opts.topo)
if (isLocalIp(dest, ours) || ours.has(dest)) return false
if (opts.serverType === "exit-node" && opts.topo) {
const client =
resolveClient(opts.topo, opts.serverId, inName)
?? (outName ? resolveClient(opts.topo, opts.serverId, outName) : null)
if (!client && isOverlayTunnelIface(opts.topo, opts.serverId, inName)) return false
}
return true
}
export function wanIfaceLabel(serverName: string, iface: string): string {
return `${serverName} · ${iface} · ${STATISTICS_WAN_MARK}`
}
export function overlayDupLabel(serverName: string, iface: string): string {
return `${serverName} · ${iface} · ${STATISTICS_DUP_MARK}`
}
export function isNonUniqueShareLabel(label: string): boolean {
return label.includes(STATISTICS_WAN_MARK) || label.includes(`· ${STATISTICS_DUP_MARK}`)
}
@@ -0,0 +1,141 @@
import assert from "node:assert/strict"
import { dbQuery } from "../db/index.js"
import { withPgOrSkip } from "../test/pg.js"
import { ensurePartitionFor } from "../db/partitions.js"
import { pool } from "../db/index.js"
import { applySqlMigrations } from "../db/migrate.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import { resetEngineForTests } from "./traffic-flow-engine.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
if (!(await withPgOrSkip())) {
console.log("traffic-flow-facts-rebuild.test.ts: skip")
process.exit(0)
}
await applySqlMigrations(pool)
const nServers = (await dbQuery<{ n: number }>(`SELECT COUNT(*)::int AS n FROM servers`)).rows[0]?.n ?? 0
if (nServers > 10) {
console.warn("traffic-flow-facts-rebuild.test.ts: skip (не пустая БД)")
process.exit(0)
}
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
disableRipeEnqueueForTests()
resetRipeCacheForTests()
resetEngineForTests()
resetIfaceCacheForTests()
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "95.167.0.0/16",
asn: 12389,
country: "RU",
lat: null,
lng: null,
holder: "ROSTELECOM-AS",
ok: true,
fetchedAt: Date.now(),
})
const inserted = await dbQuery<{ id: number }>(`
INSERT INTO servers (name, host, type, wan_uplinks)
VALUES ('rebuild-facts-jh', '203.0.113.10', 'jump-host', '[{"iface":"ether1"}]'::jsonb)
RETURNING id
`)
const serverId = inserted.rows[0]?.id
if (serverId == null) throw new Error("no server")
const ts = new Date()
await ensurePartitionFor(pool, "flow_buckets", "day", ts)
await ensurePartitionFor(pool, "flow_hour_facts", "day", ts)
await ensurePartitionFor(pool, "flow_daily_facts", "month", ts)
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM app_users WHERE id = 'u-rebuild-1'`)
await dbQuery(`
INSERT INTO app_users (id, name, login, role, active)
VALUES ('u-rebuild-1', 'Клиент', 'rebuild-user', 'viewer', TRUE)
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('bind-rebuild-1', 'u-rebuild-1', $1, 'gre-client', 'gre')
`, [serverId])
await dbQuery(`
INSERT INTO server_snapshots (server_id, polled_at, status, raw_interfaces)
VALUES ($1, now(), 'online', $2::jsonb)
`, [serverId, JSON.stringify([{ name: "gre-client", type: "gre-tunnel" }, { name: "ether1", type: "ether" }])])
rememberServerIfaces(serverId, [{ name: "gre-client", ifindex: "2" }])
invalidateFlowCatalogCache()
const bucketAt = new Date(Date.UTC(
ts.getUTCFullYear(),
ts.getUTCMonth(),
ts.getUTCDate(),
ts.getUTCHours(),
0, 0, 0,
)).toISOString()
await dbQuery(`
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface)
VALUES
($1, $2, '95.167.1.10', '10.200.100.53', 6, 51234, 443, 100, 2, 'gre-client', 'ether1'),
($1, $2, '95.167.1.10', '8.8.8.8', 6, 51234, 443, 50, 1, 'gre-client', 'ether1')
`, [serverId, bucketAt])
try {
const { rebuildFlowFactsFromBuckets } = await import("./traffic-flow-facts-rebuild.js")
const result = await rebuildFlowFactsFromBuckets()
assert.equal(result.ok, true)
assert.ok(result.buckets >= 2)
const rows = await dbQuery<{ asn: number; bytes: number }>(`
SELECT asn, SUM(bytes)::bigint AS bytes
FROM flow_hour_facts
WHERE server_id = $1
GROUP BY asn
`, [serverId])
const byAsn = new Map(rows.rows.map((r) => [Number(r.asn), Number(r.bytes)]))
const total = [...byAsn.values()].reduce((s, n) => s + n, 0)
assert.equal(total, 50)
assert.equal(byAsn.get(12389), undefined, "ASN клиента не в hour facts")
assert.equal(byAsn.get(15169), 50)
assert.equal(byAsn.get(0), undefined)
} finally {
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM server_snapshots WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM app_users WHERE id = 'u-rebuild-1'`)
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
resetEngineForTests()
invalidateFlowCatalogCache()
}
console.log("traffic-flow-facts-rebuild.test.ts: ok")
@@ -0,0 +1,145 @@
import { dbAll, dbGet, dbQuery, withAdvisoryLock } from "../db/index.js"
import { flushEngineNow } from "./traffic-flow-engine.js"
import { resolveInternetDest } from "./traffic-flow-dest.js"
import {
bumpFlowFact,
discardPendingFacts,
flushFlowFacts,
hourBucketIso,
} from "./traffic-flow-facts.js"
import { shouldWriteFlowFact } from "./traffic-flow-facts-filter.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { getServerCatalog, loadFlowTopology } from "./traffic-flow-topology.js"
export const FACT_REBUILD_LOCK_KEY = 8_723_104
const BATCH = 4_000
export interface FlowFactsRebuildResult {
ok: true
buckets: number
facts: number
days: string[]
}
function hourFromBucket(raw: Date | string): string {
const iso = raw instanceof Date ? raw.toISOString() : String(raw)
const ms = new Date(iso).getTime()
return hourBucketIso(Number.isFinite(ms) ? ms : Date.now())
}
export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildResult> {
return await withAdvisoryLock(FACT_REBUILD_LOCK_KEY, async () => {
await flushEngineNow({ prune: false })
discardPendingFacts()
const days = await dbAll<{ day: string }>(`
SELECT DISTINCT (bucket_at AT TIME ZONE 'UTC')::date::text AS day
FROM flow_buckets
ORDER BY 1
`)
const dayList = days.map((r) => r.day).filter(Boolean)
if (dayList.length === 0) {
return { ok: true as const, buckets: 0, facts: 0, days: [] }
}
await dbQuery(
`DELETE FROM flow_hour_facts WHERE (bucket_at AT TIME ZONE 'UTC')::date = ANY(?::date[])`,
[dayList],
)
await dbQuery(
`DELETE FROM flow_daily_facts WHERE day = ANY(?::date[])`,
[dayList],
)
const topo = await loadFlowTopology()
const catalog = await getServerCatalog()
let offset = 0
let buckets = 0
for (;;) {
const rows = await dbAll<{
serverId: number
bucketAt: Date | string
src: string
dst: string
proto: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
outIface: string
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
}>(`
SELECT server_id AS "serverId", bucket_at AS "bucketAt",
host(src) AS src, host(dst) AS dst, proto, src_port AS "srcPort", dst_port AS "dstPort",
bytes, packets, in_iface AS "inIface", COALESCE(out_iface, '') AS "outIface",
COALESCE(host(nat_src), '') AS "natSrc", COALESCE(host(nat_dst), '') AS "natDst",
COALESCE(nat_src_port, 0) AS "natSrcPort", COALESCE(nat_dst_port, 0) AS "natDstPort"
FROM flow_buckets
ORDER BY bucket_at, server_id
LIMIT ? OFFSET ?
`, [BATCH, offset])
if (rows.length === 0) break
for (const row of rows) {
buckets += 1
const serverType = catalog.byId.get(row.serverId)?.type
const destMeta = resolveInternetDest({
src: row.src,
dst: row.dst,
proto: Number(row.proto) || 0,
srcPort: Number(row.srcPort) || 0,
dstPort: Number(row.dstPort) || 0,
serverId: row.serverId,
inIface: row.inIface,
topo,
natSrc: row.natSrc,
natDst: row.natDst,
natSrcPort: Number(row.natSrcPort) || 0,
natDstPort: Number(row.natDstPort) || 0,
})
if (!shouldWriteFlowFact({
serverId: row.serverId,
serverType,
inIface: row.inIface,
outIface: row.outIface,
proto: Number(row.proto) || 0,
srcPort: Number(row.srcPort) || 0,
dstPort: Number(row.dstPort) || 0,
src: row.src,
dst: row.dst,
topo,
dest: destMeta.dest,
natSrc: row.natSrc,
natDst: row.natDst,
natSrcPort: Number(row.natSrcPort) || 0,
natDstPort: Number(row.natDstPort) || 0,
})) continue
bumpFlowFact({
serverId: row.serverId,
bucketAt: hourFromBucket(row.bucketAt),
iface: canonicalFactIface(row.serverId, row.inIface),
country: destMeta.country || "XX",
service: destMeta.classified.service,
asn: destMeta.asn,
bytes: Number(row.bytes) || 0,
packets: Number(row.packets) || 0,
})
}
offset += rows.length
if (rows.length < BATCH) break
}
const facts = await flushFlowFacts()
const range = await dbGet<{ n: number }>(`SELECT COUNT(*)::int AS n FROM flow_buckets`)
return {
ok: true as const,
buckets: Number(range?.n) || buckets,
facts,
days: dayList,
}
})
}
@@ -275,6 +275,10 @@ export function resetFactsForTests(): void {
ensuredParts.clear() ensuredParts.clear()
} }
export function discardPendingFacts(): void {
hourFacts.clear()
}
export function factsSnapshotForTests(): FactRow[] { export function factsSnapshotForTests(): FactRow[] {
const parsed: FactRow[] = [] const parsed: FactRow[] = []
for (const [k, acc] of hourFacts) { for (const [k, acc] of hourFacts) {
@@ -22,8 +22,9 @@ rememberServerIfaces(7, [
{ ".id": "*A", name: "wg-flow" }, { ".id": "*A", name: "wg-flow" },
{ ".id": "*D", name: "bridge" }, { ".id": "*D", name: "bridge" },
]) ])
assert.equal(resolveIfaceName(7, "2").name, "ether1") assert.equal(resolveIfaceName(7, "2").name, "ether1")
assert.equal(resolveIfaceName(7, "10").name, "wg-flow") assert.equal(resolveIfaceName(7, "#2").name, "ether1")
assert.equal(resolveIfaceName(7, "10").name, "wg-flow")
assert.equal(resolveIfaceName(7, "13").name, "bridge") assert.equal(resolveIfaceName(7, "13").name, "bridge")
assert.equal(resolveIfaceName(7, "0").name, "—") assert.equal(resolveIfaceName(7, "0").name, "—")
assert.equal(resolveIfaceName(7, "ether1").name, "ether1") assert.equal(resolveIfaceName(7, "ether1").name, "ether1")
@@ -3,7 +3,10 @@ import {
bindingIfaceAliases, bindingIfaceAliases,
bindingIfaceAliasesAllServers, bindingIfaceAliasesAllServers,
canonicalFactIface, canonicalFactIface,
collapseServerIfaceRows,
displayFactIface,
expandBindingIfaces, expandBindingIfaces,
factIfaceAliases,
rememberServerIfaces, rememberServerIfaces,
resetIfaceCacheForTests, resetIfaceCacheForTests,
resolveIfaceName, resolveIfaceName,
@@ -18,12 +21,20 @@ assert.equal(canonicalFactIface(1, "2"), "gre-client")
assert.equal(canonicalFactIface(1, "gre-client"), "gre-client") assert.equal(canonicalFactIface(1, "gre-client"), "gre-client")
assert.equal(canonicalFactIface(1, "9"), "9") assert.equal(canonicalFactIface(1, "9"), "9")
assert.equal(resolveIfaceName(1, "9").name, "#9") assert.equal(resolveIfaceName(1, "9").name, "#9")
assert.equal(resolveIfaceName(1, "2").name, "gre-client")
assert.equal(resolveIfaceName(1, "#2").name, "gre-client")
assert.equal(displayFactIface(1, "2"), "gre-client")
const aliases = bindingIfaceAliases(1, "gre-client") const aliases = bindingIfaceAliases(1, "gre-client")
assert.ok(aliases.includes("gre-client")) assert.ok(aliases.includes("gre-client"))
assert.ok(aliases.includes("2")) assert.ok(aliases.includes("2"))
assert.ok(aliases.includes("#2")) assert.ok(aliases.includes("#2"))
const fromIndex = factIfaceAliases("2", 1)
assert.ok(fromIndex.includes("gre-client"))
assert.ok(fromIndex.includes("2"))
assert.ok(fromIndex.includes("#2"))
const all = bindingIfaceAliasesAllServers("gre-client") const all = bindingIfaceAliasesAllServers("gre-client")
assert.ok(all.includes("2")) assert.ok(all.includes("2"))
@@ -31,5 +42,17 @@ const expanded = expandBindingIfaces([{ serverId: 1, iface: "gre-client" }])
assert.ok(expanded.some((x) => x.iface === "2")) assert.ok(expanded.some((x) => x.iface === "2"))
assert.ok(expanded.some((x) => x.iface === "gre-client")) assert.ok(expanded.some((x) => x.iface === "gre-client"))
const collapsed = collapseServerIfaceRows([
{ serverId: 1, iface: "2", bytes: 10, packets: 1 },
{ serverId: 1, iface: "gre-client", bytes: 5, packets: 2 },
{ serverId: 1, iface: "wan1", bytes: 3, packets: 1 },
])
assert.equal(collapsed.length, 2)
const gre = collapsed.find((r) => r.iface === "gre-client")
assert.ok(gre)
assert.equal(gre.bytes, 15)
assert.equal(gre.packets, 3)
assert.ok(collapsed.some((r) => r.iface === "wan1"))
resetIfaceCacheForTests() resetIfaceCacheForTests()
console.log("traffic-flow-ifindex.test.ts: ok") console.log("traffic-flow-ifindex.test.ts: ok")
+84 -10
View File
@@ -36,12 +36,12 @@ export function rememberServerIfaces(serverId: number, rows: RosIfaceIndexRow[])
export function resolveIfaceName(serverId: number, indexOrName: string): { name: string; index: string } { export function resolveIfaceName(serverId: number, indexOrName: string): { name: string; index: string } {
const trimmed = String(indexOrName ?? "").trim() const trimmed = String(indexOrName ?? "").trim()
if (!trimmed || trimmed === "0") return { name: "—", index: trimmed } const asIndex = trimmed.startsWith("#") && /^\d+$/.test(trimmed.slice(1)) ? trimmed.slice(1) : trimmed
if (!/^\d+$/.test(trimmed)) return { name: trimmed, index: "" } if (!asIndex || asIndex === "0") return { name: "—", index: asIndex }
const idx = Number(trimmed) if (!/^\d+$/.test(asIndex)) return { name: trimmed, index: "" }
const name = cache.get(serverId)?.get(idx) const name = cache.get(serverId)?.get(Number(asIndex))
if (name) return { name, index: trimmed } if (name) return { name, index: asIndex }
return { name: `#${trimmed}`, index: trimmed } return { name: `#${asIndex}`, index: asIndex }
} }
/** Имя iface для факта куба: ifIndex→имя, без `#13` при пустом кэше. */ /** Имя iface для факта куба: ifIndex→имя, без `#13` при пустом кэше. */
@@ -53,18 +53,86 @@ export function canonicalFactIface(serverId: number, inIface: string): string {
return name || trimmed return name || trimmed
} }
function numericIfaceIndex(iface: string): string | null {
const raw = String(iface ?? "").trim()
if (/^\d+$/.test(raw)) return raw
if (raw.startsWith("#") && /^\d+$/.test(raw.slice(1))) return raw.slice(1)
return null
}
/** Имя для UI: ifIndex → RouterOS name; `0` → «—»; miss → `#n`. */
export function displayFactIface(serverId: number, iface: string): string {
return resolveIfaceName(serverId, iface).name
}
/** Склеить факты `2` + `ether1` в одну строку после резолва ifIndex. */
export function collapseServerIfaceRows(
rows: Array<{ serverId: number; iface: string; bytes: number; packets: number }>,
): Array<{ serverId: number; iface: string; bytes: number; packets: number }> {
const acc = new Map<string, { serverId: number; iface: string; bytes: number; packets: number }>()
for (const r of rows) {
const name = displayFactIface(r.serverId, r.iface)
const k = `${r.serverId}\0${name}`
const prev = acc.get(k)
const bytes = Number(r.bytes) || 0
const packets = Number(r.packets) || 0
if (prev) {
prev.bytes += bytes
prev.packets += packets
} else {
acc.set(k, { serverId: r.serverId, iface: name, bytes, packets })
}
}
return [...acc.values()]
}
/** Ключи факта для фильтра: имя, ifIndex и `#n`. */
export function factIfaceAliases(iface: string, serverId?: number): string[] {
const raw = String(iface ?? "").trim()
if (!raw) return []
const out = new Set<string>([raw])
const idx = numericIfaceIndex(raw)
if (idx) {
out.add(idx)
out.add(`#${idx}`)
const n = Number(idx)
if (serverId != null) {
const name = cache.get(serverId)?.get(n)
if (name) out.add(name)
} else {
for (const map of cache.values()) {
const name = map.get(n)
if (name) out.add(name)
}
}
}
if (serverId != null) {
for (const a of bindingIfaceAliases(serverId, raw)) out.add(a)
} else {
for (const a of bindingIfaceAliasesAllServers(raw)) out.add(a)
}
return [...out]
}
/** Имя + ifIndex + `#n` — тот же матч, что карта `/traffic`. */ /** Имя + ifIndex + `#n` — тот же матч, что карта `/traffic`. */
export function bindingIfaceAliases(serverId: number, interfaceName: string): string[] { export function bindingIfaceAliases(serverId: number, interfaceName: string): string[] {
const name = String(interfaceName ?? "").trim() const name = String(interfaceName ?? "").trim()
if (!name) return [] if (!name) return []
const out = new Set<string>([name]) const out = new Set<string>([name])
const map = cache.get(serverId) const map = cache.get(serverId)
if (!map) return [...out] const idx = numericIfaceIndex(name)
for (const [idx, n] of map) { const canonical = (idx && map?.get(Number(idx))) || name
if (n !== name) continue out.add(canonical)
out.add(String(idx)) if (idx) {
out.add(idx)
out.add(`#${idx}`) out.add(`#${idx}`)
} }
if (!map) return [...out]
for (const [i, n] of map) {
if (n !== canonical && n !== name) continue
out.add(String(i))
out.add(`#${i}`)
}
return [...out] return [...out]
} }
@@ -93,6 +161,12 @@ export function expandBindingIfaces(
return out return out
} }
export function listCachedIfaceNames(serverId: number): string[] {
const map = cache.get(serverId)
if (!map) return []
return [...new Set(map.values())]
}
export function ifaceCacheHas(serverId: number): boolean { export function ifaceCacheHas(serverId: number): boolean {
return cache.has(serverId) return cache.has(serverId)
} }
@@ -288,6 +288,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
prev.packets += row.packets prev.packets += row.packets
if (row.outIface && !prev.outIface) prev.outIface = row.outIface if (row.outIface && !prev.outIface) prev.outIface = row.outIface
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
if (row.natSrc && !prev.natSrc) prev.natSrc = row.natSrc
if (row.natDst && !prev.natDst) prev.natDst = row.natDst
if (row.natSrcPort && !prev.natSrcPort) prev.natSrcPort = row.natSrcPort
if (row.natDstPort && !prev.natDstPort) prev.natDstPort = row.natDstPort
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
return return
@@ -326,6 +330,10 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
nextHop: r.nextHop ?? "", nextHop: r.nextHop ?? "",
flowStartMs: r.flowStartMs ?? 0, flowStartMs: r.flowStartMs ?? 0,
flowEndMs: r.flowEndMs ?? 0, flowEndMs: r.flowEndMs ?? 0,
natSrc: r.natSrc ?? "",
natDst: r.natDst ?? "",
natSrcPort: r.natSrcPort ?? 0,
natDstPort: r.natDstPort ?? 0,
}) })
} }
if (!worker) { if (!worker) {
+58 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { isNonPublicIp, pickInternetPeer } from "./traffic-flow-ip.js" import { isNonPublicIp, pickInternetDest, pickInternetPeer } from "./traffic-flow-ip.js"
assert.equal(isNonPublicIp("10.200.100.53"), true) assert.equal(isNonPublicIp("10.200.100.53"), true)
assert.equal(isNonPublicIp("173.194.151.65"), false) assert.equal(isNonPublicIp("173.194.151.65"), false)
@@ -22,4 +22,61 @@ assert.equal(
) )
assert.equal(pickInternetPeer("10.1.1.1", "10.2.2.2", 443, 80), "10.2.2.2") assert.equal(pickInternetPeer("10.1.1.1", "10.2.2.2", 443, 80), "10.2.2.2")
const rost = "95.167.1.10"
const ours = new Set(["198.51.100.1", "203.0.113.10"])
const client = { ours, boundClient: true }
assert.equal(
pickInternetDest("10.200.100.53", "104.18.35.51", 53880, 443, client),
"104.18.35.51",
"RFC1918 → CF на client GRE",
)
assert.equal(
pickInternetDest("173.194.151.65", "10.200.100.53", 443, 57182, client),
"173.194.151.65",
"Google:443 → RFC1918 на client GRE",
)
assert.equal(
pickInternetDest(rost, "10.200.100.53", 51234, 443, client),
"",
"Rostelecom → overlay 10.x: не dest ASN клиента",
)
assert.equal(
pickInternetDest(rost, "8.8.8.8", 51234, 443, client),
"8.8.8.8",
"Rostelecom → Google:443 на client GRE",
)
assert.equal(
pickInternetDest(rost, "1.1.1.1", 51234, 40000, client),
"1.1.1.1",
"оба публичные без well-known на client GRE → dst",
)
assert.equal(
pickInternetDest("8.8.8.8", "198.51.100.1", 443, 51234, { ours }),
"8.8.8.8",
"ours как dst: dest = публичный src",
)
assert.equal(
pickInternetDest("203.0.113.10", "198.51.100.1", 0, 0, { ours }),
"",
"JH ours → EN ours: dest нет",
)
assert.equal(
pickInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, {
...client,
natDst: "8.8.8.8",
natDstPort: 443,
}),
"8.8.8.8",
"RFC1918 + NAT Google",
)
assert.equal(
pickInternetDest("10.200.100.53", "10.200.100.1", 53880, 443, {
...client,
natDst: "0.0.0.0",
}),
"",
"NAT 0.0.0.0 не dest",
)
console.log("traffic-flow-ip.test.ts: ok") console.log("traffic-flow-ip.test.ts: ok")
+79 -12
View File
@@ -55,20 +55,87 @@ export function isNonPublicIp(ip: string): boolean {
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853]) const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
export function isUnspecifiedIp(ip: string): boolean {
const t = String(ip ?? "").trim()
if (!t) return true
const lower = t.toLowerCase()
return t === "0.0.0.0" || lower === "::" || lower === "::0"
}
function usableIp(ip: string | undefined): string {
const t = String(ip ?? "").trim()
return isUnspecifiedIp(t) ? "" : t
}
export interface InternetDestCtx {
/** WAN IP узлов сети (EN/JH) — не интернет-назначение. */
ours?: ReadonlySet<string>
/** Ingress с bound GRE/WG клиента: dest = нелокальный IP, не ASN клиента. */
boundClient?: boolean
/** IPFIX postNAT (IANA 225/226). */
natSrc?: string
natDst?: string
/** IPFIX postNAPT ports (IANA 227/228). */
natSrcPort?: number
natDstPort?: number
}
export function isLocalIp(ip: string, ours?: ReadonlySet<string>): boolean {
if (isUnspecifiedIp(ip) || isNonPublicIp(ip)) return true
return Boolean(ours?.has(String(ip ?? "").trim()))
}
/** /**
* Интернет-сторона потока: у IPFIX сервис часто в src (Google:443 RFC1918:ephemeral). * Интернет-назначение потока для ASN/страны/сервиса.
* Классифицировать этот IP, не слепой dst. * Пустая строка dest нет (не GeoIP IP клиента / GRE-пира).
*/ */
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string { export function pickInternetDest(
const srcPub = !isNonPublicIp(src) srcRaw: string,
const dstPub = !isNonPublicIp(dst) dstRaw: string,
if (srcPub && !dstPub) return src srcPort: number,
if (dstPub && !srcPub) return dst dstPort: number,
if (srcPub && dstPub) { ctx?: InternetDestCtx,
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort) ): string {
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort) const ours = ctx?.ours
if (srcWk && !dstWk) return src const src = usableIp(srcRaw)
if (dstWk && !srcWk) return dst const dst = usableIp(dstRaw)
const natSrc = usableIp(ctx?.natSrc)
const natDst = usableIp(ctx?.natDst)
const internet = (ip: string) => Boolean(ip) && !isLocalIp(ip, ours)
const dstIp = internet(dst) ? dst : (internet(natDst) ? natDst : "")
const srcIp = internet(src) ? src : (internet(natSrc) ? natSrc : "")
const dstPortEff = internet(dst) ? dstPort : (internet(natDst) ? (ctx?.natDstPort || dstPort) : dstPort)
const srcPortEff = internet(src) ? srcPort : (internet(natSrc) ? (ctx?.natSrcPort || srcPort) : srcPort)
if (ctx?.boundClient) {
if (dstIp) return dstIp
if (srcIp) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPortEff)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPortEff)
if (srcWk && !dstWk) return srcIp
return ""
}
return ""
} }
if (srcIp && !dstIp) return srcIp
if (dstIp && !srcIp) return dstIp
if (srcIp && dstIp) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPortEff)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPortEff)
if (srcWk && !dstWk) return srcIp
if (dstWk && !srcWk) return dstIp
return dstIp
}
if (src && dst && ours?.has(src) && ours.has(dst)) return ""
return dst return dst
} }
/**
* Интернет-сторона потока без топологии: у IPFIX сервис часто в src (Google:443 RFC1918).
* Для куба статистики используйте pickInternetDest.
*/
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
return pickInternetDest(src, dst, srcPort, dstPort) || dst
}
@@ -450,6 +450,72 @@ try {
resetFlowCatalogForTests() resetFlowCatalogForTests()
} }
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
{
src: "10.100.1.17",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 4_000,
packets: 10,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
},
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 2_000_000,
packets: 400,
inIface: "3",
outIface: "3",
},
{
src: "10.200.100.53",
dst: "10.200.100.1",
proto: 6,
srcPort: 53880,
dstPort: 443,
bytes: 3_000,
packets: 8,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
natDst: "8.8.8.8",
natDstPort: 443,
},
])
try {
resetFlowMapHopsCacheForTests()
const path = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false, minSharePct: 0 })
const hop = path.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(hop, "hop JH→EN")
const google = path.services?.find((s) => s.id === "svc:google")
assert.ok(google, "сервис Google")
assert.equal(google.bytes, 7_000)
assert.ok(!(path.services ?? []).some((s) => s.label === "GRE"), "GRE не dest")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests() resetFlowRingsForTests()
resetIfaceCacheForTests() resetIfaceCacheForTests()
resetRipeCacheForTests() resetRipeCacheForTests()
+20 -28
View File
@@ -2,18 +2,14 @@ import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { userInterfaceBindings } from "../db/schema.js" import { userInterfaceBindings } from "../db/schema.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js" import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { import { mapServiceNodeId } from "./traffic-flow-brands.js"
isNamedInternetService,
mapServiceNodeId,
resolveFlowBrand,
} from "./traffic-flow-brands.js"
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js" import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js" import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js" import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js" import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js" import { classifyInternetBrand, destCtxForIface } from "./traffic-flow-dest.js"
import { type FlowIpMeta } from "./traffic-flow-ripe.js" import { pickInternetDest } from "./traffic-flow-ip.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js" import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js" import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog, type FlowTopology } from "./traffic-flow-topology.js" import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog, type FlowTopology } from "./traffic-flow-topology.js"
@@ -185,22 +181,6 @@ function toHop(a: HopAcc, windowSec: number): FlowMapHop {
} }
} }
/** Имя бренда без каталога EvoBGP — только ASN/CIDR кэш + proto. */
function classifyMapDstLite(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): { service: string; category: string } | null {
if (proto === 47 || proto === 50) return null
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
async function resolveMinSharePct(q: FlowMapHopsQuery): Promise<number> { async function resolveMinSharePct(q: FlowMapHopsQuery): Promise<number> {
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct) if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
try { try {
@@ -354,9 +334,21 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const inName = resolveIfaceName(r.serverId, r.inIface).name const inName = resolveIfaceName(r.serverId, r.inIface).name
const outName = resolveIfaceName(r.serverId, r.outIface).name const outName = resolveIfaceName(r.serverId, r.outIface).name
totalBytes += r.bytes totalBytes += r.bytes
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort) const dest = pickInternetDest(
r.src,
r.dst,
r.srcPort,
r.dstPort,
destCtxForIface(topo, r.serverId, inName, {
natSrc: r.natSrc,
natDst: r.natDst,
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
}),
)
if (!dest) continue
const client = resolveMapClient(topo, r.serverId, inName, outName) const client = resolveMapClient(topo, r.serverId, inName, outName)
const prevDst = dstAcc.get(peer) const prevDst = dstAcc.get(dest)
if (prevDst) { if (prevDst) {
prevDst.bytes += r.bytes prevDst.bytes += r.bytes
bumpFrom(prevDst, String(r.serverId), r.bytes, client) bumpFrom(prevDst, String(r.serverId), r.bytes, client)
@@ -369,7 +361,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
fromBytes: new Map(), fromBytes: new Map(),
} }
bumpFrom(acc, String(r.serverId), r.bytes, client) bumpFrom(acc, String(r.serverId), r.bytes, client)
dstAcc.set(peer, acc) dstAcc.set(dest, acc)
} }
} }
@@ -425,7 +417,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
for (const [dst, acc] of dstAcc) { for (const [dst, acc] of dstAcc) {
const ripe = resolveFlowIp(dst) const ripe = resolveFlowIp(dst)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe) const classified = classifyInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
if (!classified) continue if (!classified) continue
const toId = mapServiceNodeId(classified.service) const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId) const prevSvc = svcTotals.get(toId)
@@ -114,6 +114,8 @@ async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
"last-forwarded": "yes", "last-forwarded": "yes",
"nat-src-address": "yes", "nat-src-address": "yes",
"nat-dst-address": "yes", "nat-dst-address": "yes",
"nat-src-port": "yes",
"nat-dst-port": "yes",
}) })
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/ipfix")) const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/ipfix"))
const id = rows[0] ? rosRowId(rows[0]) : "" const id = rows[0] ? rosRowId(rows[0]) : ""
@@ -182,6 +182,95 @@ resetFlowTemplatesForTests()
assert.equal(extra[0]?.bytes, 1500) assert.equal(extra[0]?.bytes, 1500)
} }
resetFlowTemplatesForTests()
{
const fieldSpecs: Array<[number, number]> = [
[8, 4],
[12, 4],
[225, 4],
[226, 4],
[227, 2],
[228, 2],
[1, 4],
]
const tplSetLen = 4 + 4 + fieldSpecs.length * 4
const tpl = Buffer.alloc(16 + tplSetLen)
tpl.writeUInt16BE(10, 0)
tpl.writeUInt16BE(tpl.length, 2)
tpl.writeUInt16BE(2, 16)
tpl.writeUInt16BE(tplSetLen, 18)
tpl.writeUInt16BE(256, 20)
tpl.writeUInt16BE(fieldSpecs.length, 22)
let off = 24
for (const [type, len] of fieldSpecs) {
tpl.writeUInt16BE(type, off)
tpl.writeUInt16BE(len, off + 2)
off += 4
}
const recLen = fieldSpecs.reduce((n, [, len]) => n + len, 0)
const data = Buffer.alloc(16 + 4 + recLen)
data.writeUInt16BE(10, 0)
data.writeUInt16BE(data.length, 2)
data.writeUInt16BE(256, 16)
data.writeUInt16BE(4 + recLen, 18)
let d = 20
data[d] = 10; data[d + 1] = 200; data[d + 2] = 100; data[d + 3] = 53; d += 4
data[d] = 10; data[d + 1] = 200; data[d + 2] = 100; data[d + 3] = 1; d += 4
data[d] = 0; data[d + 1] = 0; data[d + 2] = 0; data[d + 3] = 0; d += 4
data[d] = 8; data[d + 1] = 8; data[d + 2] = 8; data[d + 3] = 8; d += 4
data.writeUInt16BE(53880, d); d += 2
data.writeUInt16BE(443, d); d += 2
data.writeUInt32BE(900, d)
parseFlowPacket(tpl, "10.255.254.9")
const nat = parseFlowPacket(data, "10.255.254.9")
assert.equal(nat.length, 1)
assert.equal(nat[0]?.src, "10.200.100.53")
assert.equal(nat[0]?.dst, "10.200.100.1")
assert.equal(nat[0]?.natSrc, "0.0.0.0")
assert.equal(nat[0]?.natDst, "8.8.8.8")
assert.equal(nat[0]?.natSrcPort, 53880)
assert.equal(nat[0]?.natDstPort, 443)
assert.equal(nat[0]?.bytes, 900)
}
resetFlowTemplatesForTests()
{
const fieldSpecs: Array<[number, number]> = [
[225, 4],
[12, 4],
[1, 4],
]
const tplSetLen = 4 + 4 + fieldSpecs.length * 4
const tpl = Buffer.alloc(16 + tplSetLen)
tpl.writeUInt16BE(10, 0)
tpl.writeUInt16BE(tpl.length, 2)
tpl.writeUInt16BE(2, 16)
tpl.writeUInt16BE(tplSetLen, 18)
tpl.writeUInt16BE(256, 20)
tpl.writeUInt16BE(fieldSpecs.length, 22)
let off = 24
for (const [type, len] of fieldSpecs) {
tpl.writeUInt16BE(type, off)
tpl.writeUInt16BE(len, off + 2)
off += 4
}
const recLen = fieldSpecs.reduce((n, [, len]) => n + len, 0)
const data = Buffer.alloc(16 + 4 + recLen)
data.writeUInt16BE(10, 0)
data.writeUInt16BE(data.length, 2)
data.writeUInt16BE(256, 16)
data.writeUInt16BE(4 + recLen, 18)
let d = 20
data[d] = 0; data[d + 1] = 0; data[d + 2] = 0; data[d + 3] = 0; d += 4
data[d] = 8; data[d + 1] = 8; data[d + 2] = 8; data[d + 3] = 8; d += 4
data.writeUInt32BE(10, d)
parseFlowPacket(tpl, "10.255.254.10")
const zeroNat = parseFlowPacket(data, "10.255.254.10")
assert.equal(zeroNat[0]?.src, "")
assert.equal(zeroNat[0]?.natSrc, "0.0.0.0")
assert.equal(zeroNat[0]?.dst, "8.8.8.8")
}
resetFlowTemplatesForTests() resetFlowTemplatesForTests()
{ {
const tpl = Buffer.alloc(16 + 16 + 20) const tpl = Buffer.alloc(16 + 16 + 20)
+23 -3
View File
@@ -13,6 +13,8 @@ export interface ParsedFlow {
flowEndMs: number flowEndMs: number
natSrc: string natSrc: string
natDst: string natDst: string
natSrcPort: number
natDstPort: number
} }
export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes"> export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes">
@@ -33,6 +35,8 @@ export function emptyParsedFlow(): ParsedFlow {
flowEndMs: 0, flowEndMs: 0,
natSrc: "", natSrc: "",
natDst: "", natDst: "",
natSrcPort: 0,
natDstPort: 0,
} }
} }
@@ -45,6 +49,8 @@ export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
flowEndMs: flow.flowEndMs ?? 0, flowEndMs: flow.flowEndMs ?? 0,
natSrc: flow.natSrc ?? "", natSrc: flow.natSrc ?? "",
natDst: flow.natDst ?? "", natDst: flow.natDst ?? "",
natSrcPort: flow.natSrcPort ?? 0,
natDstPort: flow.natDstPort ?? 0,
inIface: flow.inIface ?? "", inIface: flow.inIface ?? "",
outIface: flow.outIface ?? "", outIface: flow.outIface ?? "",
srcPort: flow.srcPort ?? 0, srcPort: flow.srcPort ?? 0,
@@ -86,6 +92,12 @@ function ipv4(buf: Buffer, offset: number): string {
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}` return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
} }
function usableIpfixIp(ip: string): boolean {
const t = String(ip ?? "").trim()
if (!t) return false
return t !== "0.0.0.0" && t.toLowerCase() !== "::" && t.toLowerCase() !== "::0"
}
function ipv6(buf: Buffer, offset: number): string { function ipv6(buf: Buffer, offset: number): string {
const parts: string[] = [] const parts: string[] = []
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16)) for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
@@ -214,6 +226,8 @@ function recordFromFields(
let flowEndMs = 0 let flowEndMs = 0
let natSrc = "" let natSrc = ""
let natDst = "" let natDst = ""
let natSrcPort = 0
let natDstPort = 0
for (const f of fields) { for (const f of fields) {
const field = consumeField(buf, off, f.length, limit) const field = consumeField(buf, off, f.length, limit)
if (!field) return null if (!field) return null
@@ -243,15 +257,21 @@ function recordFromFields(
case 225: case 225:
if (data.length === 4) { if (data.length === 4) {
natSrc = ipv4(data, 0) natSrc = ipv4(data, 0)
if (!src) src = natSrc if (!usableIpfixIp(src) && usableIpfixIp(natSrc)) src = natSrc
} }
break break
case 226: case 226:
if (data.length === 4) { if (data.length === 4) {
natDst = ipv4(data, 0) natDst = ipv4(data, 0)
if (!dst) dst = natDst if (!usableIpfixIp(dst) && usableIpfixIp(natDst)) dst = natDst
} }
break break
case 227:
natSrcPort = readUint(data, 0, data.length)
break
case 228:
natDstPort = readUint(data, 0, data.length)
break
case 4: case 4:
proto = readUint(data, 0, data.length) proto = readUint(data, 0, data.length)
break break
@@ -308,7 +328,7 @@ function recordFromFields(
if (ifaceName && !inIface) inIface = ifaceName if (ifaceName && !inIface) inIface = ifaceName
return { return {
flow: normalizeParsedFlow({ flow: normalizeParsedFlow({
src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst, src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst, natSrcPort, natDstPort,
}), }),
next: off, next: off,
} }
+51 -3
View File
@@ -1,7 +1,7 @@
import { db, dbAll } from "../db/index.js" import { db, dbAll } from "../db/index.js"
import { parseJsonArray } from "../db/json.js" import { parseJsonArray } from "../db/json.js"
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js" import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
import { mapRosInterfaceType } from "../modules/users/iface-type.js" import { mapRosInterfaceType, parseRawInterfaces } from "../modules/users/iface-type.js"
import type { PlaneTopology } from "./traffic-flow-planes.js" import type { PlaneTopology } from "./traffic-flow-planes.js"
export interface FlowClientBinding { export interface FlowClientBinding {
@@ -25,6 +25,8 @@ export interface FlowTopology {
enHosts: Set<string> enHosts: Set<string>
jhHosts: Set<string> jhHosts: Set<string>
wanIfaces: Map<number, Set<string>> wanIfaces: Map<number, Set<string>>
/** GRE/WG из последнего снимка RouterOS (`type`), без mgmt. */
tunnelIfaces?: Map<number, Set<string>>
plane: PlaneTopology plane: PlaneTopology
} }
@@ -48,6 +50,15 @@ export function invalidateFlowCatalogCache(): void {
serverCatalogCache = null serverCatalogCache = null
} }
export function peekFlowTopology(): FlowTopology | null {
if (seeded) return seeded
return topologyCache?.topo ?? null
}
export function peekServerCatalog(): { list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } | null {
return serverCatalogCache
}
export async function getServerCatalog(): Promise<{ list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> }> { export async function getServerCatalog(): Promise<{ list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> }> {
const now = Date.now() const now = Date.now()
if (serverCatalogCache && now - serverCatalogCache.at < CATALOG_TTL_MS) { if (serverCatalogCache && now - serverCatalogCache.at < CATALOG_TTL_MS) {
@@ -76,6 +87,25 @@ function ifaceKey(serverId: number, name: string): string {
return `${serverId}|${name}` return `${serverId}|${name}`
} }
async function loadTunnelIfacesFromSnapshots(): Promise<Map<number, Set<string>>> {
const rows = await dbAll<{ serverId: number; rawInterfaces: unknown }>(`
SELECT DISTINCT ON (server_id) server_id AS "serverId", raw_interfaces AS "rawInterfaces"
FROM server_snapshots
ORDER BY server_id, polled_at DESC
`)
const map = new Map<number, Set<string>>()
for (const r of rows) {
const set = new Set<string>()
for (const iface of parseRawInterfaces(r.rawInterfaces)) {
if (iface.type !== "gre" && iface.type !== "wg") continue
if (iface.name.toLowerCase() === "wg-flow") continue
set.add(iface.name)
}
if (set.size) map.set(r.serverId, set)
}
return map
}
export async function loadFlowTopology(): Promise<FlowTopology> { export async function loadFlowTopology(): Promise<FlowTopology> {
if (seeded) return seeded if (seeded) return seeded
const now = Date.now() const now = Date.now()
@@ -118,6 +148,7 @@ export async function loadFlowTopology(): Promise<FlowTopology> {
for (const h of hosts) jhHosts.add(h) for (const h of hosts) jhHosts.add(h)
} }
} }
const tunnelIfaces = await loadTunnelIfacesFromSnapshots()
const topo: FlowTopology = { const topo: FlowTopology = {
clientIfaces, clientIfaces,
clientByIface, clientByIface,
@@ -125,6 +156,7 @@ export async function loadFlowTopology(): Promise<FlowTopology> {
enHosts, enHosts,
jhHosts, jhHosts,
wanIfaces, wanIfaces,
tunnelIfaces,
plane: { plane: {
clientIfaceNames: allClientNames, clientIfaceNames: allClientNames,
enHosts, enHosts,
@@ -140,6 +172,18 @@ export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
invalidateFlowCatalogCache() invalidateFlowCatalogCache()
} }
export function flowOursHosts(topo: FlowTopology | null | undefined): Set<string> {
const ours = new Set<string>()
if (!topo) return ours
for (const h of topo.enHosts) {
if (h) ours.add(h)
}
for (const h of topo.jhHosts) {
if (h) ours.add(h)
}
return ours
}
export function resolveClient( export function resolveClient(
topo: FlowTopology, topo: FlowTopology,
serverId: number, serverId: number,
@@ -168,10 +212,14 @@ export function resolveEn(
export function enGreIfaceNames(topo: FlowTopology, serverId: number, ifaceNames: string[]): string[] { export function enGreIfaceNames(topo: FlowTopology, serverId: number, ifaceNames: string[]): string[] {
const client = topo.clientIfaces.get(serverId) ?? new Set<string>() const client = topo.clientIfaces.get(serverId) ?? new Set<string>()
const wan = topo.wanIfaces.get(serverId) ?? new Set<string>()
const typed = topo.tunnelIfaces?.get(serverId)
return ifaceNames.filter((name) => { return ifaceNames.filter((name) => {
if (client.has(name)) return false if (client.has(name) || wan.has(name)) return false
if (name === "wg-flow") return false if (name === "wg-flow") return false
return mapRosInterfaceType("", name) === "gre" if (typed && typed.size > 0) return typed.has(name)
const t = mapRosInterfaceType("", name)
return t === "gre" || t === "wg"
}) })
} }
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict"
import { mapVxlanRow } from "./vxlan-live.js"
const server = {
id: 7,
name: "mt-msk",
host: "10.0.0.1",
site: "MSK",
country: "RU",
} as Parameters<typeof mapVxlanRow>[0]
const row = mapVxlanRow(
server,
{
".id": "*3",
name: "vxlan-10",
vni: "10010",
port: "8472",
"local-address": "10.0.0.1",
running: "true",
disabled: "false",
l2mtu: "1500",
"mac-learning": "true",
"arp-proxy": "true",
comment: "overlay",
},
[
{ interface: "vxlan-10", "remote-ip": "10.0.1.1" },
{ interface: "other", "remote-ip": "1.1.1.1" },
{ interface: "vxlan-10", "remote-ip": "10.0.2.1" },
],
0,
)
assert.equal(row.serverId, "7")
assert.equal(row.vni, 10010)
assert.equal(row.dstPort, 8472)
assert.equal(row.status, "up")
assert.equal(row.enabled, true)
assert.deepEqual(row.remoteVteps, ["10.0.1.1", "10.0.2.1"])
assert.equal(row.vtepIp, "10.0.0.1")
console.log("vxlan-live.test.ts: ok")
+136
View File
@@ -0,0 +1,136 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosVxlan {
".id"?: string
name?: string
vni?: string
port?: string
"local-address"?: string
"vtep-address"?: string
running?: string
disabled?: string
comment?: string
l2mtu?: string
arp?: string
"arp-proxy"?: string
"mac-learning"?: string
learning?: string
}
interface RosVxlanVtep {
".id"?: string
interface?: string
"remote-ip"?: string
}
export type VxlanTunnelLive = {
id: string
rosId: string
name: string
vni: number
port: number
dstPort: number
serverId: string
vtepIp: string
remoteVteps: string[]
l2mtu: number
arpProxy: boolean
macLearning: boolean
comment: string
enabled: boolean
status: "up" | "down"
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function parseIntSafe(v: string | undefined, fallback: number): number {
const n = Number.parseInt(v ?? "", 10)
return Number.isFinite(n) ? n : fallback
}
export function mapVxlanRow(
server: ServerRow,
vx: RosVxlan,
vteps: RosVxlanVtep[],
idx: number,
): VxlanTunnelLive {
const name = (vx.name ?? "").trim() || `vxlan-${idx + 1}`
const rosId = String(vx[".id"] ?? name)
const disabled = rosYes(vx.disabled)
const running = rosYes(vx.running)
const port = parseIntSafe(vx.port, 8472)
const remoteVteps = vteps
.filter((v) => (v.interface ?? "").trim() === name)
.map((v) => (v["remote-ip"] ?? "").trim())
.filter(Boolean)
return {
id: `${server.id}-${rosId}`,
rosId,
name,
vni: parseIntSafe(vx.vni, 0),
port: 0,
dstPort: port,
serverId: String(server.id),
vtepIp: (vx["local-address"] ?? vx["vtep-address"] ?? "").trim(),
remoteVteps,
l2mtu: parseIntSafe(vx.l2mtu, 1500),
arpProxy: rosYes(vx["arp-proxy"]) || vx.arp === "proxy-arp" || vx.arp === "enabled",
macLearning: vx["mac-learning"] != null ? rosYes(vx["mac-learning"]) : vx.learning !== "false",
comment: vx.comment ?? "",
enabled: !disabled,
status: !disabled && running ? "up" : "down",
}
}
async function fetchVxlanForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
const client = MikrotikClient.fromServer(server)
const [vxRaw, vtepRaw] = await Promise.all([
client.get<RosVxlan[]>("/interface/vxlan"),
client.get<RosVxlanVtep[]>("/interface/vxlan/vteps").catch(() => [] as RosVxlanVtep[]),
])
const list = Array.isArray(vxRaw) ? vxRaw : []
const vteps = Array.isArray(vtepRaw) ? vtepRaw : []
return list.map((vx, idx) => mapVxlanRow(server, vx, vteps, idx))
}
export async function listVxlanTunnels(): Promise<VxlanTunnelLive[]> {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
return await fetchVxlanForServer(server)
} catch {
return [] as VxlanTunnelLive[]
}
}),
)
return results.flat()
}
export async function listVxlanTunnelsForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
try {
return await fetchVxlanForServer(server)
} catch {
return []
}
}
export async function countVxlanTunnels(): Promise<number> {
try {
const result = await Promise.race([
listVxlanTunnels(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.length
} catch {
return 0
}
}
+87
View File
@@ -3,6 +3,13 @@ import { db } from "../db/index.js"
import { servers } from "../db/schema.js" import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js" import { MikrotikClient } from "./mikrotik.js"
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard" import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
import {
canonicalWireguardSnapshot,
type WgLiveAddr,
type WgLiveIface,
type WgLivePeer,
type WgSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect type ServerRow = typeof servers.$inferSelect
@@ -35,6 +42,7 @@ interface RosWireGuardPeer {
"client-address"?: string "client-address"?: string
"client-dns"?: string "client-dns"?: string
"client-endpoint"?: string "client-endpoint"?: string
"private-key"?: string
} }
interface RosIpAddress { interface RosIpAddress {
@@ -152,6 +160,85 @@ async function fetchForServer(
}) })
} }
export async function fetchWireguardRestoreState(server: ServerRow): Promise<{
client: MikrotikClient
ifaces: WgLiveIface[]
peers: WgLivePeer[]
addrs: WgLiveAddr[]
snapshot: WgSnapshot
}> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const ifaces: WgLiveIface[] = (Array.isArray(ifacesRaw) ? ifacesRaw : []).map((w) => ({
name: (w.name ?? "").trim(),
rosId: String(w[".id"] ?? w.name ?? ""),
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
privateKey: w["private-key"] ?? "",
comment: w.comment ?? "",
disabled: w.disabled === "true" || w.disabled === "yes",
}))
const peers: WgLivePeer[] = (Array.isArray(peersRaw) ? peersRaw : []).map((p, idx) => {
const mapped = mapPeer(p, idx)
const ep = (p["endpoint-address"] ?? "").trim()
const port = (p["endpoint-port"] ?? "").trim()
const ka = p["persistent-keepalive"] ? Number.parseInt(p["persistent-keepalive"], 10) : NaN
return {
rosId: mapped.rosId,
interfaceName: (p.interface ?? "").trim(),
publicKey: mapped.publicKey,
allowedAddresses: mapped.allowedIps,
endpointAddress: ep,
endpointPort: port,
persistentKeepalive: Number.isFinite(ka) ? ka : null,
comment: mapped.comment ?? "",
name: mapped.name ?? "",
disabled: mapped.disabled === true,
privateKey: p["private-key"] ?? "",
clientAddress: mapped.clientAddress ?? "",
clientDns: mapped.clientDns ?? "",
clientEndpoint: mapped.clientEndpoint ?? "",
}
})
const addrs: WgLiveAddr[] = []
for (const a of Array.isArray(addrsRaw) ? addrsRaw : []) {
if (a.disabled === "true" || a.disabled === "yes") continue
const iface = (a.interface ?? "").trim()
const address = (a.address ?? "").trim()
const rosId = String(a[".id"] ?? "")
if (!iface || !address || !rosId) continue
if (!ifaces.some((i) => i.name === iface)) continue
addrs.push({ rosId, interfaceName: iface, address })
}
const snapshot = canonicalWireguardSnapshot({
interfaces: ifaces.map((iface) => ({
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
privateKey: iface.privateKey,
address: addrs.find((a) => a.interfaceName === iface.name)?.address ?? "",
comment: iface.comment,
disabled: iface.disabled,
peers: peers.filter((p) => p.interfaceName === iface.name),
})),
})
return { client, ifaces, peers, addrs, snapshot }
}
export async function captureWireguardSnapshot(server: ServerRow): Promise<WgSnapshot> {
const state = await fetchWireguardRestoreState(server)
return state.snapshot
}
export type WgListResult = { export type WgListResult = {
interfaces: WgIfaceDto[] interfaces: WgIfaceDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }> failures: Array<{ serverId: string; serverName?: string; error: string }>
+17
View File
@@ -285,6 +285,19 @@ export interface OspfInterfaceRead {
useBfd: boolean useBfd: boolean
} }
export interface OspfRouteRead {
id: string
serverId: number
serverName: string
serverSite: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
nextHop: string
via: string
area: string
}
export interface OspfInstanceRead { export interface OspfInstanceRead {
id: string id: string
serverId: number serverId: number
@@ -306,6 +319,7 @@ export interface RosIpRoute {
"dst-address": string "dst-address": string
"pref-src"?: string "pref-src"?: string
"gateway"?: string "gateway"?: string
"immediate-gw"?: string
"distance"?: string "distance"?: string
"scope"?: string "scope"?: string
"active"?: string // "true" "active"?: string // "true"
@@ -314,6 +328,9 @@ export interface RosIpRoute {
"connect"?: string "connect"?: string
"bgp"?: string "bgp"?: string
"ospf"?: string "ospf"?: string
"ospf-type"?: string
"ospf-metric"?: string
"ospf-area"?: string
"rip"?: string "rip"?: string
"blackhole"?: string "blackhole"?: string
"unreachable"?: string "unreachable"?: string
+12 -5
View File
@@ -106,7 +106,15 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
}, },
] ]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number } type LiveSidebarCounts = SidebarCountsDto & {
greTunnels?: number
certificates?: number
wireguard?: number
users?: number
bgpSessions?: number
vxlan?: number
containers?: number
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl, prefsHydrated } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -171,10 +179,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0) if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0) if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0) if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/bgp") return formatSidebarBadgeCount(liveCounts.bgpSessions ?? 0)
if (url === "/containers" || url === "/bgp") { if (url === "/vxlan") return formatSidebarBadgeCount(liveCounts.vxlan ?? 0)
return undefined if (url === "/containers") return formatSidebarBadgeCount(liveCounts.containers ?? 0)
}
return undefined return undefined
} }
+185
View File
@@ -0,0 +1,185 @@
"use client"
import { useMemo, useState, type ComponentProps } from "react"
import { HistoryIcon, LoaderCircleIcon, RotateCcwIcon } from "lucide-react"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame"
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { EmptyState } from "@/components/empty-state"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { TriangleAlertIcon } from "lucide-react"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
const SOURCE_LABEL: Record<ConfigRevisionDto["source"], string> = {
apply: "Изменение",
rollback: "Откат",
observed: "С роутера",
copy: "Копирование",
}
const SOURCE_BADGE: Record<ConfigRevisionDto["source"], ComponentProps<typeof Badge>["variant"]> = {
apply: "primary-light",
rollback: "warning-light",
observed: "secondary",
copy: "success-light",
}
function formatWhen(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" })
}
export function ConfigHistorySheet({
open,
onOpenChange,
title,
itemLabel,
revisions,
loading,
restoring,
onRestore,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
itemLabel: string
revisions: ConfigRevisionDto[]
loading: boolean
restoring: boolean
onRestore: (id: string) => Promise<void> | void
}) {
const [pending, setPending] = useState<ConfigRevisionDto | null>(null)
const newestFirst = useMemo(() => revisions, [revisions])
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{title}</SheetTitle>
<SheetDescription>
Снапшоты managed-объектов. Откат применяет выбранную версию на CHR.
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
Загрузка истории
</div>
) : newestFirst.length === 0 ? (
<EmptyState
icon={<HistoryIcon className="size-4" />}
title="Истории пока нет"
description="Снапшот появится после первого чтения или изменения на роутере"
className="py-12"
/>
) : (
<Frame>
<FramePanel>
<Timeline value={newestFirst.length} className="px-1">
{newestFirst.map((rev, idx) => (
<TimelineItem key={rev.id} step={newestFirst.length - idx}>
<TimelineHeader>
<TimelineDate dateTime={rev.createdAt}>{formatWhen(rev.createdAt)}</TimelineDate>
<TimelineTitle className="flex items-center gap-2 flex-wrap">
<Badge variant={SOURCE_BADGE[rev.source]} size="sm">
{SOURCE_LABEL[rev.source]}
</Badge>
<span className="text-muted-foreground font-normal tabular-nums">
{rev.itemCount} {itemLabel}
</span>
</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>
<Button
type="button"
variant="outline"
size="sm"
disabled={restoring}
onClick={() => setPending(rev)}
>
<RotateCcwIcon className="size-3.5" />
Откатить
</Button>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
</FramePanel>
</Frame>
)}
</div>
</SheetContent>
</Sheet>
<AlertDialog open={Boolean(pending)} onOpenChange={(v) => { if (!v && !restoring) setPending(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-warning/10 text-warning">
<TriangleAlertIcon />
</AlertDialogMedia>
<AlertDialogTitle>Откатить на эту версию?</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-3">
<span>
На CHR будут применены {pending?.itemCount ?? 0} {itemLabel} от{" "}
{pending ? formatWhen(pending.createdAt) : ""}.
</span>
<Alert variant="warning">
<TriangleAlertIcon />
<AlertTitle>Изменятся только объекты MikrotikManager</AlertTitle>
<AlertDescription>
Чужие правила и маршруты на роутере не удаляются.
</AlertDescription>
</Alert>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={restoring} onClick={() => setPending(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction
disabled={restoring || !pending}
onClick={() => {
if (!pending) return
void Promise.resolve(onRestore(pending.id)).finally(() => setPending(null))
}}
>
{restoring ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Откатить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+1 -77
View File
@@ -11,7 +11,6 @@ import type { FilterRule, GreTunnel, Server } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import { import {
DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD,
@@ -22,21 +21,15 @@ import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sor
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { import {
AlertCircleIcon, AlertCircleIcon,
AlertTriangleIcon,
CheckCircle2Icon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
CircleDashedIcon,
PencilIcon, PencilIcon,
RouteIcon, RouteIcon,
StarIcon, StarIcon,
TrashIcon, TrashIcon,
XCircleIcon,
FilterIcon, FilterIcon,
} from "lucide-react" } from "lucide-react"
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
export interface RecursiveRouteLite { export interface RecursiveRouteLite {
id: string id: string
dstAddress: string dstAddress: string
@@ -47,42 +40,6 @@ export interface RecursiveRouteLite {
disabled: boolean disabled: boolean
} }
function RouterSyncMarker({
status,
}: {
status: FilterRouterSyncStatus | null | "skip"
}) {
if (status === "skip") {
return <span className="size-3.5 shrink-0 block" aria-hidden />
}
const icon =
status === "synced"
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
: status === "drift"
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
: status === "missing"
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
const title =
status === "synced"
? "Совпадает с цепочкой bgp-in на MikroTik"
: status === "drift"
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
: status === "missing"
? "Эта community не найдена в правиле bgp-in на роутере"
: "Не проверено — нажмите «Сверить с роутером»"
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
{icon}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{title}
</TooltipContent>
</Tooltip>
)
}
function innerIpToGateway(ip: string) { function innerIpToGateway(ip: string) {
return ip.split("/")[0] return ip.split("/")[0]
} }
@@ -234,8 +191,6 @@ interface FiltersDataGridProps {
serversList: Server[] serversList: Server[]
communityNameMap: Record<string, string> communityNameMap: Record<string, string>
recursiveRoutes: RecursiveRouteLite[] recursiveRoutes: RecursiveRouteLite[]
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
isLive?: boolean
enableSorting?: boolean enableSorting?: boolean
onEdit: (rule: FilterRule) => void onEdit: (rule: FilterRule) => void
onDelete: (id: string) => void onDelete: (id: string) => void
@@ -249,8 +204,6 @@ function FiltersDataGrid({
serversList, serversList,
communityNameMap, communityNameMap,
recursiveRoutes, recursiveRoutes,
routerSyncByCommunity,
isLive,
enableSorting = false, enableSorting = false,
onEdit, onEdit,
onDelete, onDelete,
@@ -308,33 +261,6 @@ function FiltersDataGrid({
size: 28, size: 28,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
}, },
{
id: "routerSync",
header: () => (
<Tooltip>
<TooltipTrigger className="cursor-help font-mono text-xs text-muted-foreground border-0 bg-transparent p-0">
MT
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
Совпадение с MikroTik (bgp-in)
</TooltipContent>
</Tooltip>
),
enableSorting: false,
cell: ({ row }) => (
<RouterSyncMarker
status={
!isLive
? "skip"
: !routerSyncByCommunity
? null
: routerSyncByCommunity[row.original.community.trim()] ?? null
}
/>
),
size: 32,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{ {
id: "community", id: "community",
accessorKey: "community", accessorKey: "community",
@@ -413,13 +339,11 @@ function FiltersDataGrid({
[ [
communityNameMap, communityNameMap,
enableSorting, enableSorting,
isLive,
onDelete, onDelete,
onEdit, onEdit,
onMoveDown, onMoveDown,
onMoveUp, onMoveUp,
recursiveRoutes, recursiveRoutes,
routerSyncByCommunity,
serversList, serversList,
tunnelsList, tunnelsList,
], ],
@@ -454,4 +378,4 @@ function FiltersDataGrid({
) )
} }
export { FiltersDataGrid, RouterSyncMarker, type FiltersDataGridProps } export { FiltersDataGrid, type FiltersDataGridProps }
@@ -75,7 +75,7 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
} }
function TunnelStatus({ status }: { status: GreStatus }) { function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status] const s = STATUS_MAP[status] ?? STATUS_MAP.degraded
return ( return (
<span className="inline-flex items-center gap-1.5 text-sm"> <span className="inline-flex items-center gap-1.5 text-sm">
<span className={cn("size-1.5 rounded-full", s.dot)} /> <span className={cn("size-1.5 rounded-full", s.dot)} />
@@ -101,6 +101,10 @@ interface GreTunnelsDataGridProps {
servers: Server[] servers: Server[]
pools: GrePool[] pools: GrePool[]
onCodePreview: (tunnel: GreTunnel) => void onCodePreview: (tunnel: GreTunnel) => void
onEdit?: (tunnel: GreTunnel) => void
onToggle?: (tunnel: GreTunnel) => void
onDelete?: (tunnel: GreTunnel) => void
mutationsLocked?: boolean
} }
function GreTunnelsDataGrid({ function GreTunnelsDataGrid({
@@ -108,6 +112,10 @@ function GreTunnelsDataGrid({
servers, servers,
pools, pools,
onCodePreview, onCodePreview,
onEdit,
onToggle,
onDelete,
mutationsLocked = false,
}: GreTunnelsDataGridProps) { }: GreTunnelsDataGridProps) {
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers]) const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools]) const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
@@ -200,14 +208,21 @@ function GreTunnelsDataGrid({
cell: ({ row }) => { cell: ({ row }) => {
const t = row.original const t = row.original
if (!t.ipsec) return <span className="text-xs text-muted-foreground"></span> if (!t.ipsec) return <span className="text-xs text-muted-foreground"></span>
const enc = t.ipsec.encAlg ? ENC_LABELS[t.ipsec.encAlg] : undefined
const auth = t.ipsec.authAlg ? AUTH_LABELS[t.ipsec.authAlg] : undefined
const dh = t.ipsec.dhGroup ? DH_LABELS[t.ipsec.dhGroup] : undefined
const ike = t.ipsec.ikeVersion ? IKE_LABELS[t.ipsec.ikeVersion] : undefined
if (!enc && !auth && !dh && !ike) {
return <span className="text-xs text-muted-foreground">PSK · auto</span>
}
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-xs font-mono"> <span className="text-xs font-mono">
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]} {[enc, auth].filter(Boolean).join(" / ") || "PSK"}
</span> </span>
<span className="text-xs text-muted-foreground font-mono"> <span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]} {[dh?.split(" ")[0], ike].filter(Boolean).join(" · ")}
{t.ipsec.pfs && " · PFS"} {t.ipsec.pfs ? " · PFS" : ""}
</span> </span>
</div> </div>
) )
@@ -297,16 +312,20 @@ function GreTunnelsDataGrid({
<DropdownMenuItem onClick={() => onCodePreview(t)}> <DropdownMenuItem onClick={() => onCodePreview(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода <CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem disabled={mutationsLocked || !onEdit} onClick={() => onEdit?.(t)}>
<PencilIcon className="size-4" /> Редактировать <PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem disabled={mutationsLocked || !onToggle} onClick={() => onToggle?.(t)}>
<PowerIcon className="size-4" /> <PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"} {t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"> <DropdownMenuItem
variant="destructive"
disabled={mutationsLocked || !onDelete}
onClick={() => onDelete?.(t)}
>
<Trash2Icon className="size-4" /> Удалить туннель <Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -318,7 +337,7 @@ function GreTunnelsDataGrid({
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
}, },
], ],
[onCodePreview, poolMap, serverMap], [onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, poolMap, serverMap],
) )
const table = useReactTable({ const table = useReactTable({
@@ -5,7 +5,12 @@ import { Flag } from "@/components/flag"
import { Badge } from "@/components/reui/badge" import { Badge } from "@/components/reui/badge"
import { fmtBps, formatBytes } from "@/lib/fmt-rate" import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { STATISTICS_UNBOUND_USER_ID, type StatisticsBreakdownRow } from "@mmapp/contracts/statistics" import {
STATISTICS_DUP_MARK,
STATISTICS_UNBOUND_USER_ID,
STATISTICS_WAN_MARK,
type StatisticsBreakdownRow,
} from "@mmapp/contracts/statistics"
export type StatisticsSliceKind = "users" | "servers" | "interfaces" | "countries" | "services" | "asns" export type StatisticsSliceKind = "users" | "servers" | "interfaces" | "countries" | "services" | "asns"
@@ -72,7 +77,14 @@ export function StatisticsBreakdownDataGrid({
id: "percent", id: "percent",
header: "Доля", header: "Доля",
accessorKey: "percent", accessorKey: "percent",
cell: (row) => <span className="tabular-nums">{row.percent.toFixed(1)}%</span>, cell: (row) => (
<span className="tabular-nums">
{row.percent === 0
&& (row.label.includes(STATISTICS_WAN_MARK) || row.label.includes(`· ${STATISTICS_DUP_MARK}`))
? "—"
: `${row.percent.toFixed(1)}%`}
</span>
),
}, },
] ]
+13 -8
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo } from "react" import { useCallback, useMemo, useState } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ru } from "date-fns/locale" import { ru } from "date-fns/locale"
import { CalendarIcon } from "lucide-react" import { CalendarIcon } from "lucide-react"
@@ -130,14 +130,19 @@ export function PeriodSelector({
range: DateRangeYmd range: DateRangeYmd
onChange: (next: DateRangeYmd) => void onChange: (next: DateRangeYmd) => void
}) { }) {
const [open, setOpen] = useState(false)
const selectorValue = useMemo(() => rangeToSelector(range), [range]) const selectorValue = useMemo(() => rangeToSelector(range), [range])
function handleSelectorChange(value: DateSelectorValue) { const handleSelectorChange = useCallback(
const next = dateSelectorToRange(value) (value: DateSelectorValue) => {
if (!next) return const next = dateSelectorToRange(value)
if (next.from === range.from && next.to === range.to) return if (!next) return
onChange(next) if (next.from === range.from && next.to === range.to) return
} onChange(next)
setOpen(false)
},
[onChange, range.from, range.to],
)
const activePreset = PRESETS.find((p) => { const activePreset = PRESETS.find((p) => {
const r = rangeForPreset(p.id) const r = rangeForPreset(p.id)
@@ -157,7 +162,7 @@ export function PeriodSelector({
{p.label} {p.label}
</Button> </Button>
))} ))}
<Popover> <Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" /> <Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" />
+13 -2
View File
@@ -264,7 +264,7 @@ export function useDashboardLive() {
setInternetPathLoading(true) setInternetPathLoading(true)
} }
try { try {
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes] = await Promise.allSettled([ const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([
apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"), apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
apiFetch<BackendServerRow[]>("/api/servers"), apiFetch<BackendServerRow[]>("/api/servers"),
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
@@ -273,6 +273,7 @@ export function useDashboardLive() {
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"), apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"),
listWireGuard(backendUrl), listWireGuard(backendUrl),
apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"), apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"),
apiFetch<{ tunnels?: Array<{ id: string; name: string; status: OverlayItem["status"] }> }>("/api/vxlan"),
]) ])
const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected" const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected"
@@ -333,7 +334,17 @@ export function useDashboardLive() {
status: iface.status, status: iface.status,
})) }))
: [] : []
setOverlayItems([...greItems, ...wgItems]) const vxItems: OverlayItem[] =
vxRes.status === "fulfilled"
? (vxRes.value.tunnels ?? []).map((t) => ({
id: `vx-${t.id}`,
name: t.name,
kind: "vxlan" as const,
href: "/vxlan",
status: t.status === "up" ? "up" : "down",
}))
: []
setOverlayItems([...greItems, ...wgItems, ...vxItems])
if (trafficRes.status === "fulfilled") { if (trafficRes.status === "fulfilled") {
const rows = trafficRes.value.servers ?? [] const rows = trafficRes.value.servers ?? []
+12
View File
@@ -0,0 +1,12 @@
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
export interface ConfigRevisionDto {
id: string
serverId: string
section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre"
source: ConfigRevisionSource
fingerprint: string
createdAt: string
note: string | null
itemCount: number
}
+8 -6
View File
@@ -233,6 +233,7 @@ export interface VrfInstance {
export interface RouterContainer { export interface RouterContainer {
id: string id: string
rosId?: string
name: string name: string
serverId: string serverId: string
image: string // e.g. "nginx:alpine" image: string // e.g. "nginx:alpine"
@@ -530,12 +531,13 @@ export type DscpMode = "inherit" | number
export interface GreIpsec { export interface GreIpsec {
secret: string // ipsec-secret → auto-creates peer+policy+proposal secret: string // ipsec-secret → auto-creates peer+policy+proposal
encAlg: IpsecEncAlg // proposal enc-algorithms /** Live CHR отдаёт только secret; proposal-поля есть у моков / формы */
authAlg: IpsecAuthAlg // proposal auth-algorithms encAlg?: IpsecEncAlg
dhGroup: IpsecDhGroup // proposal pfs-group / peer dh-group authAlg?: IpsecAuthAlg
ikeVersion: IkeVersion // peer exchange-mode dhGroup?: IpsecDhGroup
lifetime: string // proposal lifetime (e.g. "1d 00:00:00") ikeVersion?: IkeVersion
pfs: boolean // perfect forward secrecy lifetime?: string
pfs?: boolean
} }
export interface GreTunnel { export interface GreTunnel {
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict"
import {
formatServicePathLabel,
formatServicePathTitle,
isUnboundServicePath,
} from "./format-service-path-label.ts"
const bound = {
clientId: "u1",
clientName: "D",
viaId: "7",
viaName: "nsk-gw01",
enId: "9",
enName: "arn-gw01",
serviceId: "svc:cdn",
}
const jhEn = {
clientId: "—",
clientName: "—",
viaId: "7",
viaName: "msk-gw01",
enId: "9",
enName: "arn-gw01",
serviceId: "svc:cdn",
}
const enOnly = {
clientId: "—",
clientName: "—",
viaId: "9",
viaName: "arn-gw01",
enId: "9",
enName: "arn-gw01",
serviceId: "svc:cdn",
}
assert.equal(isUnboundServicePath(jhEn), true)
assert.equal(isUnboundServicePath(bound), false)
assert.equal(
formatServicePathLabel(jhEn, "via", { viaName: "msk-gw01.rtnt.top", enName: "arn-gw01.rtnt.top" }),
"msk-gw01.rtnt.top → arn-gw01.rtnt.top",
)
assert.equal(formatServicePathLabel(enOnly, "via"), "arn-gw01 · без привязки")
assert.equal(formatServicePathLabel(enOnly, "service"), "arn-gw01 · без привязки")
assert.equal(
formatServicePathLabel(bound, "via", { viaSite: "NSK" }),
"D · NSK",
)
assert.equal(
formatServicePathLabel(bound, "service", { serviceLabel: "CDN" }),
"D · CDN",
)
assert.equal(
formatServicePathTitle("msk-gw01 → arn-gw01", "CDN"),
"msk-gw01 → arn-gw01 · CDN",
)
console.log("format-service-path-label.test.ts: ok")
+45
View File
@@ -0,0 +1,45 @@
export const UNBOUND_PATH_CLIENT = "—"
export interface ServicePathLabelInput {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
}
export interface ServicePathLabelNames {
viaName?: string
viaSite?: string
enName?: string
serviceLabel?: string
}
export function isUnboundServicePath(p: Pick<ServicePathLabelInput, "clientId" | "clientName">): boolean {
return p.clientId === UNBOUND_PATH_CLIENT || p.clientName === UNBOUND_PATH_CLIENT
}
export function formatServicePathLabel(
p: ServicePathLabelInput,
viaMode: "via" | "service",
names: ServicePathLabelNames = {},
): string {
const viaName = names.viaName || p.viaName
const enName = names.enName || p.enName
if (isUnboundServicePath(p)) {
if (p.viaId !== p.enId) return `${viaName}${enName}`
return `${enName} · без привязки`
}
const viaLabel = names.viaSite || p.viaName
const mid = viaMode === "via" ? viaLabel : (names.serviceLabel || p.serviceId)
return `${p.clientName} · ${mid}`
}
export function formatServicePathTitle(label: string, serviceLabel: string): string {
const svc = serviceLabel.trim()
if (!svc) return label
if (label.includes(svc)) return label
return `${label} · ${svc}`
}
+5
View File
@@ -9,6 +9,7 @@ import {
routerCertificates, routerCertificates,
routerContainers, routerContainers,
servers, servers,
vxlanTunnels,
} from "@/lib/data" } from "@/lib/data"
/** Число мок-сессий BGP (см. `SESSIONS` в `app/(main)/bgp/page.tsx`). */ /** Число мок-сессий BGP (см. `SESSIONS` в `app/(main)/bgp/page.tsx`). */
@@ -41,6 +42,7 @@ export function mockSidebarBadgesByUrl(): Record<string, string> {
"/filters": formatSidebarBadgeCount(filters.length), "/filters": formatSidebarBadgeCount(filters.length),
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()), "/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
"/gre": formatSidebarBadgeCount(greTunnels.length), "/gre": formatSidebarBadgeCount(greTunnels.length),
"/vxlan": formatSidebarBadgeCount(vxlanTunnels.length),
"/containers": formatSidebarBadgeCount(routerContainers.length), "/containers": formatSidebarBadgeCount(routerContainers.length),
"/certificates": formatSidebarBadgeCount(routerCertificates.length), "/certificates": formatSidebarBadgeCount(routerCertificates.length),
"/bgp": formatSidebarBadgeCount(MOCK_BGP_SESSION_COUNT), "/bgp": formatSidebarBadgeCount(MOCK_BGP_SESSION_COUNT),
@@ -57,4 +59,7 @@ export interface SidebarCountsDto {
certificates?: number certificates?: number
wireguard?: number wireguard?: number
users?: number users?: number
bgpSessions?: number
vxlan?: number
containers?: number
} }
+15
View File
@@ -14875,6 +14875,21 @@
"dependencies": { "dependencies": {
"zod": "^4.4.1" "zod": "^4.4.1"
} }
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
+3
View File
@@ -1,6 +1,8 @@
import { z } from "zod" import { z } from "zod"
export const STATISTICS_UNBOUND_USER_ID = "__unbound__" export const STATISTICS_UNBOUND_USER_ID = "__unbound__"
export const STATISTICS_WAN_MARK = "WAN · интернет"
export const STATISTICS_DUP_MARK = "дубль"
export const statisticsPivotDimSchema = z.enum([ export const statisticsPivotDimSchema = z.enum([
"country", "country",
@@ -45,6 +47,7 @@ export const statisticsQuerySchema = z.object({
country: z.string().min(2).max(2).optional(), country: z.string().min(2).max(2).optional(),
service: z.string().min(1).optional(), service: z.string().min(1).optional(),
asn: z.coerce.number().int().optional(), asn: z.coerce.number().int().optional(),
planes: z.enum(["unique", "all"]).default("unique"),
}) })
export const statisticsDtoSchema = z.object({ export const statisticsDtoSchema = z.object({
+8
View File
@@ -252,6 +252,13 @@ export const flowPurgeDtoSchema = z.object({
vacuumed: z.boolean(), vacuumed: z.boolean(),
}) })
export const flowFactsRebuildDtoSchema = z.object({
ok: z.literal(true),
buckets: z.number().int().nonnegative(),
facts: z.number().int().nonnegative(),
days: z.array(z.string()),
})
export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"]) export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"])
export const flowMapHopDtoSchema = z.object({ export const flowMapHopDtoSchema = z.object({
@@ -330,6 +337,7 @@ export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema> export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema> export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema> export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
export type FlowFactsRebuildDto = z.infer<typeof flowFactsRebuildDtoSchema>
export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema> export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema> export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema> export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
+3 -1
View File
@@ -7,7 +7,7 @@ import type {
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
export type { StatisticsDto, StatisticsQuery, StatisticsPivotDto, StatisticsPivotQuery } export type { StatisticsDto, StatisticsQuery, StatisticsPivotDto, StatisticsPivotQuery }
export { STATISTICS_UNBOUND_USER_ID } from "@mmapp/contracts/statistics" export { STATISTICS_UNBOUND_USER_ID, STATISTICS_WAN_MARK, STATISTICS_DUP_MARK } from "@mmapp/contracts/statistics"
export async function getStatistics( export async function getStatistics(
baseUrl: string, baseUrl: string,
@@ -22,6 +22,7 @@ export async function getStatistics(
if (query.country) params.set("country", query.country) if (query.country) params.set("country", query.country)
if (query.service) params.set("service", query.service) if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn)) if (query.asn != null) params.set("asn", String(query.asn))
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
return requestJson<StatisticsDto>(baseUrl, `/api/statistics?${params.toString()}`) return requestJson<StatisticsDto>(baseUrl, `/api/statistics?${params.toString()}`)
} }
@@ -41,5 +42,6 @@ export async function getStatisticsPivot(
if (query.country) params.set("country", query.country) if (query.country) params.set("country", query.country)
if (query.service) params.set("service", query.service) if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn)) if (query.asn != null) params.set("asn", String(query.asn))
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
return requestJson<StatisticsPivotDto>(baseUrl, `/api/statistics/pivot?${params.toString()}`) return requestJson<StatisticsPivotDto>(baseUrl, `/api/statistics/pivot?${params.toString()}`)
} }
+9
View File
@@ -141,4 +141,13 @@ export async function purgeTrafficFlowData(baseUrl: string): Promise<FlowPurgeDt
return requestJson<FlowPurgeDto>(baseUrl, "/api/traffic/flow/purge", { method: "POST" }) return requestJson<FlowPurgeDto>(baseUrl, "/api/traffic/flow/purge", { method: "POST" })
} }
export async function rebuildTrafficFlowFacts(baseUrl: string): Promise<{
ok: true
buckets: number
facts: number
days: string[]
}> {
return requestJson(baseUrl, "/api/traffic/flow/rebuild-facts", { method: "POST" })
}
export { flowQuery } export { flowQuery }
+1 -1
View File
File diff suppressed because one or more lines are too long