Compare commits

..
5 Commits
Author SHA1 Message Date
DenozordecandCursor 60a0970d73 feat(traffic-flow): enhance traffic flow analytics and overlay configuration
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m42s
Docker images / frontend-image (push) Successful in 3m22s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Updated the traffic flow analytics to include new state variables for named bytes, total bytes, and window seconds, improving data granularity.
- Modified the NetworkMapPage to display classified traffic data, including a new section for showing classified and total bytes.
- Enhanced the applyFlowOverlay function to support a new option for disabling GRE fast path, allowing for more flexible traffic flow configurations.
- Added a toggle in the FlowOverlaySheet component to enable or disable the GRE fast path setting, improving user control over traffic processing.
- Updated tests to validate the new functionalities and ensure accurate traffic flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 00:03:13 +07:00
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
63 changed files with 4572 additions and 844 deletions
+172 -138
View File
@@ -27,7 +27,7 @@ import {
StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
CheckCircle2Icon, XCircleIcon, CircleDashedIcon, RefreshCwIcon,
RefreshCwIcon, HistoryIcon,
} from "lucide-react"
import {
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 { toast } from "sonner"
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 { type ServerTileItem } from "@/components/server-tile-rail"
// ── helpers ────────────────────────────────────────────────────────────────────
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
@@ -1239,6 +1239,9 @@ interface BackendServer {
interface LiveFiltersResponse {
rulesets: ServerFilterRuleset[]
greTunnels: GreTunnel[]
live?: boolean
stale?: boolean
error?: string
}
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
@@ -1325,18 +1328,14 @@ export default function FiltersPage() {
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
const [editingId, setEditingId] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [copyOpen, setCopyOpen] = useState(false)
const [syncBusy, setSyncBusy] = useState<"from" | "to" | null>(null)
const [routerCompare, setRouterCompare] = useState<{
serverId: string
byCommunity: Record<string, FilterRouterSyncStatus>
} | null>(null)
const [routerCompareLoading, setRouterCompareLoading] = useState(false)
useEffect(() => {
setRouterCompare(null)
}, [selectedServerId])
const [previewOpen, setPreviewOpen] = useState(false)
const [copyOpen, setCopyOpen] = useState(false)
const [applyBusy, setApplyBusy] = useState(false)
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[]>([])
useEffect(() => {
if (!isLive) {
@@ -1347,6 +1346,7 @@ export default function FiltersPage() {
setRulesets(buildRulesets(servers, serverFilterRulesets))
setSelectedServerId(servers[0]?.id ?? "")
setLiveLoadState("idle")
setLiveStale(false)
})
return
}
@@ -1390,18 +1390,43 @@ export default function FiltersPage() {
})
}, [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(() => {
if (!isLive || !selectedServerId) return
if (!isLive || !selectedServerId || liveLoadState !== "idle") return
if (!liveServers.some((s) => s.id === selectedServerId)) return
void Promise.all([
loadLiveRules(selectedServerId),
ensureGreTunnels(selectedServerId),
ensureRecursiveRoutes(selectedServerId),
])
}, [isLive, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes])
}, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
const allServers = isLive ? liveServers : servers
const allTunnels = isLive ? (greByServer[selectedServerId] ?? []) : greTunnels
const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = isLive && (applyBusy || liveStale || liveLoadState === "error")
const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
@@ -1427,21 +1452,6 @@ export default function FiltersPage() {
[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 q = search.toLowerCase()
if (!q) return currentRules
@@ -1453,68 +1463,94 @@ export default function FiltersPage() {
)
}, [currentRules, search, communityNameMap])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
setRulesets(prev => {
const next = prev.map(rs =>
rs.serverId === serverId ? { ...rs, rules: updater(rs.rules) } : rs
)
if (isLive) {
void apiFetch<{ ok: boolean }>("/api/filters/rules", {
method: "PUT",
body: JSON.stringify({ rulesets: next }),
}).catch(() => {})
}
return next
})
}, [isLive, apiFetch])
const syncFromRouter = useCallback(async () => {
if (!isLive || syncBusy) return
setSyncBusy("from")
const applyRules = useCallback(async (
serverId: string,
nextRules: FilterRule[],
source: "apply" | "copy" = "apply",
) => {
const prev = rulesets
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
if (!isLive) return
if (liveStale) {
setRulesets(prev)
toast.error("Роутер недоступен — изменения заблокированы")
return
}
setApplyBusy(true)
try {
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
method: "PUT",
body: JSON.stringify({ serverId, rules: nextRules, source }),
})
const fresh = await apiFetch<LiveFiltersResponse>("/api/filters/rules")
setRulesets(buildRulesets(allServers, fresh.rulesets))
if (res.rules) {
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([
ensureGreTunnels(selectedServerId),
ensureRecursiveRoutes(selectedServerId),
])
await fetchRouterCompare()
} finally {
setSyncBusy(null)
setApplyBusy(false)
}
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare])
}, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
const syncToRouter = useCallback(async () => {
if (!isLive || syncBusy || !selectedServerId) return
setSyncBusy("to")
const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return
setHistoryLoading(true)
try {
const res = await apiFetch<{
ok: boolean
updatedServers: number
pushedRules: number
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()
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
)
setRevisions(res.revisions)
} catch (err) {
toast.error("Не удалось загрузить правила на роутер", { description: String(err) })
toast.error("Не удалось загрузить историю", { description: String(err) })
setRevisions([])
} 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 = () => {
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
@@ -1532,6 +1568,7 @@ export default function FiltersPage() {
}
const handleSave = (form: RuleForm) => {
if (mutationsLocked) return
const { gatewayKind: _gk, ...payload } = form
if (sheetMode === "create") {
updateRules(selectedServerId, rules => [
@@ -1549,34 +1586,30 @@ export default function FiltersPage() {
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) => {
updateRules(targetServerId, existing =>
mode === "replace" ? rules : [...existing, ...rules]
)
}, [updateRules])
const existing = rulesets.find((rs) => rs.serverId === targetServerId)?.rules ?? []
const next = mode === "replace" ? rules : [...existing, ...rules]
void applyRules(targetServerId, next, "copy")
}, [rulesets, applyRules])
const handleMoveUp = (index: number) => {
if (index === 0) return
if (mutationsLocked || index === 0) return
updateRules(selectedServerId, rules => {
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
})
}
const handleMoveDown = (index: number) => {
if (mutationsLocked) return
updateRules(selectedServerId, rules => {
if (index >= rules.length - 1) return rules
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") {
return (
<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 (
<>
<ServerRailLayout
@@ -1604,35 +1645,25 @@ export default function FiltersPage() {
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
onClick={() => void refreshFromRouter()}
disabled={applyBusy}
title="Прочитать актуальные правила с роутера"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
<RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={applyBusy}
title="История версий и откат на CHR"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</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" />
)}
Сверить
<HistoryIcon className="size-4" />
История
</Button>
</>
)}
@@ -1642,12 +1673,12 @@ export default function FiltersPage() {
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
disabled={currentRules.length === 0 || mutationsLocked}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
@@ -1662,6 +1693,12 @@ export default function FiltersPage() {
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</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="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" />
@@ -1745,15 +1782,7 @@ export default function FiltersPage() {
)}>{selectedServer.latency}мс</span>
)}
<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 && (
<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>
)}
{applyBusy && <span>Применение на роутер</span>}
<span>{currentRules.length} правил</span>
</div>
</div>
@@ -1790,12 +1819,6 @@ export default function FiltersPage() {
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncByCommunity={
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity
}
isLive={isLive}
enableSorting={!!search}
onEdit={openEdit}
onDelete={handleDelete}
@@ -1805,7 +1828,7 @@ export default function FiltersPage() {
)}
{/* 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">
<PlusIcon className="size-3.5" />
Добавить правило для {selectedServer.name}
@@ -1854,6 +1877,17 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer}
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,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
SlidersHorizontalIcon, RefreshCwIcon,
SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -1947,6 +1949,10 @@ function FirewallPageInner() {
const [exportOpen, setExportOpen] = useState(false)
const [editingAddr, setEditingAddr] = useState<Partial<AddressListEntry> | null>(null)
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 () => {
if (!isLive) return
@@ -1970,6 +1976,42 @@ function FirewallPageInner() {
}
}, [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(() => {
if (!isLive) {
queueMicrotask(() => {
@@ -2373,6 +2415,19 @@ function FirewallPageInner() {
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</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)}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</Button>
@@ -2586,6 +2641,17 @@ function FirewallPageInner() {
onClose={() => setExportOpen(false)}
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 { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
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 {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
@@ -31,7 +43,7 @@ import {
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon,
DatabaseIcon, HistoryIcon, TriangleAlertIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
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" },
}
function greStatusMeta(status: GreStatus | undefined) {
return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
}
// ─── RouterOS code generator ─────────────────────────────────────────────────
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(` interface=${t.name}`)
// IPsec manual equivalent
if (t.ipsec) {
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
// IPsec: live CHR имеет только ipsec-secret; proposal — у моков/формы
if (t.ipsec?.encAlg && t.ipsec.authAlg) {
const ikeMode = t.ipsec.ikeVersion === "ikev1" ? "ike1" : "ike2"
const pfsGroup = t.ipsec.pfs ? (t.ipsec.dhGroup ?? "none") : "none"
lines.push("")
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(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
lines.push(` pfs-group=${pfsGroup} \\`)
lines.push(` lifetime=${t.ipsec.lifetime}`)
lines.push(` lifetime=${t.ipsec.lifetime ?? "1d"}`)
lines.push("")
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(` dst-address=${t.remoteAddress}/32 \\`)
lines.push(` proposal=${t.name} \\`)
lines.push(` tunnel=yes`)
} else if (t.ipsec) {
lines.push("")
lines.push("# IPsec: peer/policy создаёт RouterOS по ipsec-secret")
}
return lines.join("\n")
@@ -126,7 +145,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
// ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status]
const s = greStatusMeta(status)
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} />
@@ -164,6 +183,7 @@ interface BackendServer {
interface GreTunnelsApiResponse {
tunnels: GreTunnel[]
failures?: Array<{ serverId: string; serverName?: string; error: string }>
}
function makeApiFetch(backendUrl: string) {
@@ -237,7 +257,6 @@ export default function GrePage() {
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
const [dataLoading, setDataLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null)
const [syncJhBusy, setSyncJhBusy] = useState(false)
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
@@ -245,6 +264,15 @@ export default function GrePage() {
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
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 [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
@@ -261,14 +289,19 @@ export default function GrePage() {
try {
const [backendServers, greRes] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
apiFetch<GreTunnelsApiResponse>("/api/gre/tunnels"),
])
setLiveServers(backendServers.map(mapBackendToServer))
setLiveTunnels(greRes.tunnels)
setLiveStale(false)
if (greRes.failures?.length) {
toast.warning(
`Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveServers([])
setLiveTunnels([])
setLiveStale(true)
} finally {
setDataLoading(false)
}
@@ -280,6 +313,7 @@ export default function GrePage() {
setLiveServers([])
setLiveTunnels([])
setDataError(null)
setLiveStale(false)
})
return
}
@@ -323,39 +357,186 @@ export default function GrePage() {
[displayPools],
)
const syncJhToDb = useCallback(async () => {
if (!isLive || syncJhBusy) return
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
if (jh.length === 0) {
toast.info("Нет включённых Jump Host в списке серверов")
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const mutationsLocked = isLive && (mutateBusy || liveStale || historyRestoring)
const loadRevisions = useCallback(async () => {
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
}
setSyncJhBusy(true)
const errors: string[] = []
try {
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)
if (liveStale) {
toast.error("Роутер недоступен — изменения заблокированы")
return
}
}, [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(() => {
if (dataError) toast.error(dataError)
@@ -403,6 +584,14 @@ export default function GrePage() {
showAll
allCount={displayServers.length}
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={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
@@ -422,14 +611,17 @@ export default function GrePage() {
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
<HistoryIcon className="size-4" />
История
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
@@ -521,6 +713,10 @@ export default function GrePage() {
servers={displayServers}
pools={displayPools}
onCodePreview={setCodePreviewTunnel}
onEdit={openEditTunnel}
onToggle={(t) => { void toggleTunnel(t) }}
onDelete={setPendingDelete}
mutationsLocked={mutationsLocked}
/>
</DataPageCard>
)}
@@ -548,7 +744,7 @@ export default function GrePage() {
<div className="flex flex-wrap gap-2">
{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">
<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="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span>
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
@@ -609,8 +805,8 @@ export default function GrePage() {
codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
<span className={`size-1.5 rounded-full ${greStatusMeta(codePreviewTunnel.status).dot}`} />
{greStatusMeta(codePreviewTunnel.status).label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
@@ -625,7 +821,7 @@ export default function GrePage() {
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
IPsec {codePreviewTunnel.ipsec.ikeVersion ? IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion] : "PSK"}
</span>
</>
) : null}
@@ -638,18 +834,18 @@ export default function GrePage() {
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
<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">
<SheetTitle>Новый GRE-туннель</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
<SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<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 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">
<option value="" disabled>Выбрать сервер</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">
<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)}
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>
@@ -798,7 +994,9 @@ export default function GrePage() {
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<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>
</SheetContent>
</Sheet>
@@ -845,6 +1043,39 @@ export default function GrePage() {
</SheetFooter>
</SheetContent>
</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>
</>
)
}
+29 -1
View File
@@ -761,7 +761,7 @@ function ServiceNode({
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
<title>{`${label} · ${serviceSharePct(share)} payload окна`}</title>
{isSel && (
<rect
x={-bw / 2 - 6}
@@ -1100,6 +1100,9 @@ export default function NetworkMapPage() {
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapSharePct, setMapSharePct] = useState(5)
const [mapNamedBytes, setMapNamedBytes] = useState(0)
const [mapTotalBytes, setMapTotalBytes] = useState(0)
const [mapWindowSec, setMapWindowSec] = useState(300)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -1196,6 +1199,8 @@ export default function NetworkMapPage() {
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapSharePct(5)
setMapNamedBytes(0)
setMapTotalBytes(0)
setDataError(null)
})
return
@@ -1303,6 +1308,9 @@ export default function NetworkMapPage() {
setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
setMapNamedBytes(res.namedBytes ?? 0)
setMapTotalBytes(res.totalBytes ?? 0)
if (res.windowSec) setMapWindowSec(res.windowSec)
})
.catch((err: unknown) => {
if (cancelled) return
@@ -2739,6 +2747,26 @@ export default function NetworkMapPage() {
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(liveSelectedService.share)}</span>
</div>
{mapTotalBytes > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Классифицировано</span>
<span className="text-xs font-mono font-medium text-cyan-400">
{formatNetflowRate({
bytes: mapNamedBytes,
bps: (mapNamedBytes * 8) / Math.max(1, mapWindowSec),
bpsFwd: 0,
bpsRev: 0,
})}
{" из "}
{formatNetflowRate({
bytes: mapTotalBytes,
bps: (mapTotalBytes * 8) / Math.max(1, mapWindowSec),
bpsFwd: 0,
bpsRev: 0,
})}
</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
+119 -58
View File
@@ -18,9 +18,12 @@ import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils"
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 { 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"
interface BackendServer {
@@ -364,7 +367,7 @@ export default function RecursiveRoutesPage() {
const [servers, setServers] = useState<Server[]>([])
const [selectedServerId, setSelectedServerId] = useState<string>("")
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 [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
@@ -373,6 +376,11 @@ export default function RecursiveRoutesPage() {
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = 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 */
const [liveServerListReady, setLiveServerListReady] = useState(false)
@@ -407,10 +415,13 @@ export default function RecursiveRoutesPage() {
setOpError(null)
setBusy("load")
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)
setLiveStale(Boolean(res.stale))
} catch (e) {
setRows([])
setLiveStale(true)
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
} finally {
setBusy(null)
@@ -437,56 +448,65 @@ export default function RecursiveRoutesPage() {
void loadGateways()
}, [loadGateways])
const saveToDb = useCallback(async () => {
if (!isLive || !selectedServerId) return
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
if (!isLive || !selectedServerId || liveStale) return
const prev = rows
setRows(next)
setOpError(null)
setBusy("save")
setBusy("apply")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
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) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
setRows(prev)
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
setOpError(msg)
toast.error(msg)
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
const syncFromRouter = useCallback(async () => {
const loadRevisions = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("from")
setHistoryLoading(true)
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
await loadRoutes()
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
)
setRevisions(res.revisions)
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
setRevisions([])
} finally {
setBusy(null)
}
}, [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)
setHistoryLoading(false)
}
}, [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 {
return row.dstAddress.trim().toLowerCase()
}
@@ -529,22 +549,26 @@ export default function RecursiveRoutesPage() {
disabled: false,
country: ep.country || inferCountry(ep.gateway) || "",
})
let next: RecursiveRouteRow[]
if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded])
next = [...rows, ...expanded]
} else if (editingGroupKey) {
setRows(prev => {
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
return [...kept, ...expanded]
})
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
next = [...kept, ...expanded]
} else {
setSheetOpen(false)
return
}
setSheetOpen(false)
void applyRoutes(next)
}
const currentServer = servers.find(s => s.id === selectedServerId)
const mutationsLocked = !isLive || busy !== null || liveStale
const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({
id: s.id,
@@ -602,16 +626,33 @@ export default function RecursiveRoutesPage() {
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={() => void loadRoutes()}
disabled={busy !== null}
title="Прочитать маршруты с роутера"
>
<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" />Добавить
</Button>
</>
@@ -643,6 +684,12 @@ export default function RecursiveRoutesPage() {
{opError}
</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>
}
>
@@ -679,10 +726,13 @@ export default function RecursiveRoutesPage() {
expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey}
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}
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">
<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 disabled:opacity-50">
<PlusIcon className="size-3.5" />
Добавить маршрут
</button>
@@ -698,6 +748,17 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={setHistoryOpen}
title="История маршрутов"
itemLabel="маршрутов"
revisions={revisions}
loading={historyLoading}
restoring={historyRestoring}
onRestore={restoreRevision}
/>
</>
)
}
+71 -1
View File
@@ -57,10 +57,12 @@ import {
type ServerTileItem,
} from "@/components/server-tile-rail"
import { toast } from "sonner"
import { ConfigHistorySheet } from "@/components/config-history-sheet"
import type { ConfigRevisionDto } from "@/lib/config-revisions"
import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
} from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
@@ -188,6 +190,10 @@ export default function WireGuardPage() {
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | 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<{
rsc?: string
conf?: string
@@ -242,6 +248,44 @@ export default function WireGuardPage() {
? selectedServerId
: 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(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
@@ -544,6 +588,7 @@ export default function WireGuardPage() {
<>
<ServerRailMobileButton />
{isLive && (
<>
<Button
size="sm"
variant="outline"
@@ -553,6 +598,20 @@ export default function WireGuardPage() {
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</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)}>
<UploadIcon className="size-4" />
@@ -822,6 +881,17 @@ export default function WireGuardPage() {
</AlertDialogFooter>
</AlertDialogContent>
</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;
+3 -2
View File
@@ -18,10 +18,11 @@
"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-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: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: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",
"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"
},
"dependencies": {
+30 -1
View File
@@ -9,6 +9,8 @@ if (!(await withPgOrSkip())) {
process.exit(0)
}
await applySqlMigrations(pool)
{
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
assert.ok(rows[0])
@@ -114,13 +116,17 @@ if (!(await withPgOrSkip())) {
SELECT column_name, udt_name
FROM information_schema.columns
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]))
assert.equal(by.src, "inet")
assert.equal(by.dst, "inet")
assert.equal(by.next_hop, "inet")
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'`)
}
{
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 }>(
`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),
])
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", {
id: idIdentity().primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
@@ -253,6 +266,10 @@ export const flowBuckets = pgTable("flow_buckets", {
nextHop: inet("next_hop"),
flowStartMs: bigint("flow_start_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) => [
primaryKey({
name: "flow_buckets_pkey",
@@ -733,6 +750,7 @@ export type ServerInsert = typeof servers.$inferInsert
export type Snapshot = typeof serverSnapshots.$inferSelect
export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type ConfigRevisionRow = typeof configRevisions.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$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"],
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
["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: [
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
+2
View File
@@ -32,6 +32,7 @@ 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 greRoutes from "./routes/gre.js"
import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
@@ -139,6 +140,7 @@ export async function buildApp(opts?: {
await app.register(vxlanRoutes, { prefix: "/api" })
await app.register(containersRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
await app.register(greRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" })
+8
View File
@@ -41,6 +41,14 @@ assert.equal(
permissionForRequest("GET", "/api/firewall/all"),
"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(
permissionForRequest("GET", "/api/users"),
"mm:users:read",
+4 -2
View File
@@ -155,7 +155,8 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:read",
},
{
@@ -168,7 +169,8 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
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 {
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)
}
+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 { db } from "../db/index.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.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 {
hasManagedCommentPrefix,
managedComment,
} from "../managed-markers.js"
appendRevisionIfChanged,
canonicalFilterRules,
getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect
@@ -296,47 +302,6 @@ async function resolveRouteTargets(serverId: number, rule: ApiFilterRule): Promi
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> {
if (rules.length === 0) return ""
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
@@ -421,44 +386,80 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
)
}
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
app.get("/filters/router-compare", 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" })
}
async function cacheRulesetsForServer(serverId: number): Promise<ApiFilterRule[]> {
const rows = await db
.select()
.from(filterRules)
.where(eq(filterRules.serverId, serverId))
.orderBy(asc(filterRules.sortOrder))
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]
if (!server) return reply.status(404).send({ error: "Server not found" })
async function applyFiltersToServer(
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 {
const remote = await fetchServerFilters(server)
const rows = await db
.select()
.from(filterRules)
.where(eq(filterRules.serverId, serverId))
.orderBy(asc(filterRules.sortOrder))
if (plan.action === "patch" && plan.managedId) {
const ruleBody = await toRouterRuleBody(server.id, rules)
await client.patch(
`/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
{
chain: "bgp-in",
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 => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
for (const id of plan.conflictIds) {
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
}
const byCommunity = await compareDbRulesWithRouter(serverId, dbRules, remote.rules)
return reply.send({ byCommunity })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
return reply.status(500).send({ error: String(err) })
}
await replaceDbRules(server.id, rules)
await appendRevisionIfChanged({
serverId: server.id,
section: "filters",
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, карты сети */
app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
@@ -487,74 +488,103 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ tunnels: results.flat() })
})
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
app.get("/filters/rules", async (_req, reply) => {
/** Без serverId — cache для дашборда. С serverId — live с CHR, cache fallback. */
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 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) {
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" })
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)
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({
level: "info",
eventType: "filters.sync.from_router.done",
eventType: "filters.apply.started",
sourceModule: "filters",
title: "Синхронизация фильтров завершена",
message: `${server.name || server.host}: ${remote.rules.length} правил`,
title: "Применение фильтров на роутер",
message: `${server.name || server.host}: ${rules.length} правил`,
entityType: "server",
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) {
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
app.log.error({ serverId, err: String(err) }, "filters apply failed")
await appendEvent({
level: "critical",
eventType: "filters.sync.from_router.failed",
eventType: "filters.apply.failed",
sourceModule: "filters",
title: "Ошибка синхронизации фильтров",
title: "Ошибка применения фильтров",
message: `${server.name || server.host}: ${String(err)}`,
entityType: "server",
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 requestedServerId = parseDbServerId(body?.serverId)
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const targetServers = requestedServerId !== null
? allServers.filter(s => s.id === requestedServerId)
: allServers
if (requestedServerId !== null && targetServers.length === 0) {
return reply.status(404).send({ error: "Server not found" })
const rev = await getRevisionById(id)
if (!rev) return reply.status(404).send({ error: "Revision not found" })
if (rev.section !== "filters") 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" })
let updatedServers = 0
let pushedRules = 0
const errors: Array<{ serverId: number; error: string }> = []
await appendEvent({
level: "info",
eventType: "filters.sync.to_router.started",
sourceModule: "filters",
title: "Отправка фильтров на роутеры запущена",
message: `Целевых серверов: ${targetServers.length}`,
payload: { requestedServerId },
})
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) })
const raw = Array.isArray(rev.payload) ? rev.payload : []
const rules: ApiFilterRule[] = raw.map((item, idx) => {
const r = item as Partial<ApiFilterRule>
return {
id: `rev-${idx}`,
community: r.community ?? "",
communityName: r.communityName,
action: r.action === "blackhole" ? "blackhole" : "route",
gateway: r.gateway ?? "",
gatewayTunnelId: r.gatewayTunnelId ?? "",
description: r.description ?? "",
}
}
})
await appendEvent({
level: errors.length === 0 ? "info" : "warning",
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial",
sourceModule: "filters",
title: errors.length === 0 ? "Отправка фильтров завершена" : "Отправка фильтров завершена с ошибками",
message: `Успешно: ${updatedServers}, ошибок: ${errors.length}, правил: ${pushedRules}`,
payload: {
updatedServers,
pushedRules,
errors,
},
})
return reply.send({
ok: errors.length === 0,
updatedServers,
pushedRules,
errors,
})
try {
const result = await applyFiltersToServer(server, rules, "rollback")
const cached = await cacheRulesetsForServer(server.id)
await appendEvent({
level: "info",
eventType: "filters.rollback.done",
sourceModule: "filters",
title: "Откат фильтров",
message: `${server.name || server.host}: ${result.pushed} правил`,
entityType: "server",
entityId: String(server.id),
})
return reply.send({ ok: true, rules: cached, pushedRules: result.pushed })
} catch (err) {
app.log.error({ serverId: server.id, err: String(err) }, "filters restore failed")
return reply.status(500).send({ error: String(err) })
}
})
}
+73 -1
View File
@@ -2,7 +2,20 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.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"
const FamilySchema = z.enum(["ip", "ip6"])
@@ -120,6 +133,20 @@ async function requireServer(serverId: string) {
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) => {
app.get("/firewall/all", async (_req, reply) => {
const data = await listFirewallAll()
@@ -138,6 +165,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
try {
await client.put(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true })
} catch (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)}`
try {
await client.patch(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (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)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (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)}`
try {
await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -213,6 +244,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
numbers: body.rosId,
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
})
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -230,6 +262,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true })
} catch (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)}`
try {
await client.patch(path, addressToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (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)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (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)}`
try {
await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (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
+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
+141 -80
View File
@@ -1,13 +1,23 @@
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
import { managedRecursiveComment } from "../managed-markers.js"
import {
hasManagedRecursiveComment,
managedRecursiveComment,
} from "../managed-markers.js"
mapRosManagedRoutes,
planRecursiveApply,
userRecursiveComment,
} from "../services/config-apply-plan.js"
import {
appendRevisionIfChanged,
canonicalRecursiveRoutes,
getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect
@@ -51,27 +61,6 @@ interface RecursiveRouteDto {
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 {
const v = raw.trim()
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> {
return {
"dst-address": route.dstAddress,
@@ -132,7 +136,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: r.comment ?? "",
comment: userRecursiveComment(r.comment),
disabled: r.disabled,
createdAt: 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) => {
app.get("/recursive-routes/gateways", async (req, reply) => {
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 serverId = parseDbServerId(q.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" })
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
.select().from(servers)
.where(eq(servers.id, serverId))
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const cached = await mapDbRoutes(serverId)
try {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const rec = rosRoutes.filter(r =>
isRecursiveRoute(r) && hasRecursiveCommentMask(r.comment),
)
const mapped: RecursiveRouteDto[] = rec.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: r.comment ?? "",
disabled: r.disabled === "true",
}))
await replaceDbRoutes(serverId, mapped)
return reply.send({ ok: true, serverId, totalRoutes: mapped.length })
const live = mergeCachedCountry(mapRosManagedRoutes(rosRoutes), cached)
await replaceDbRoutes(serverId, live)
await appendRevisionIfChanged({
serverId,
section: "recursive-routes",
source: "observed",
payload: canonicalRecursiveRoutes(live),
})
const stored = await mapDbRoutes(serverId)
return reply.send({ routes: stored, live: true, stale: false })
} catch (err) {
app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache")
return reply.send({
routes: cached,
live: false,
stale: true,
error: String(err),
})
}
})
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) {
return reply.status(500).send({ error: String(err) })
}
})
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseDbServerId(body?.serverId)
app.get("/recursive-routes/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 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" })
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 {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const managed = existing.filter(r => hasManagedRecursiveComment(r.comment ?? ""))
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 })
const result = await applyRecursiveToServer(server, routes, "rollback")
const stored = await mapDbRoutes(server.id)
return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
+1
View File
@@ -113,6 +113,7 @@ async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
const result = await applyFlowOverlay(parsed.data.serverId, {
publicEndpoint: parsed.data.publicEndpoint,
requestHost: requestPublicHost(req),
disableGreFastPath: parsed.data.disableGreFastPath,
})
return reply.send(result)
} catch (e) {
+78
View File
@@ -18,6 +18,8 @@ import {
type WgParsedConfig,
} from "../services/wireguard-config.js"
import {
captureWireguardSnapshot,
fetchWireguardRestoreState,
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
@@ -27,6 +29,16 @@ import {
putWireguardPeer,
toRosBody,
} 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 {
return decodeURIComponent(v)
@@ -137,6 +149,20 @@ function findIface(
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) => {
app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string }
@@ -145,6 +171,11 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
serverId: q.serverId,
includePrivateKey,
})
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledServerById(sid)
if (server) await recordWireguard(server, "observed")
}
return reply.send(result)
})
@@ -181,6 +212,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
includePrivateKey: true,
})
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 })
} catch (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,
}),
)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -224,6 +257,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -242,6 +276,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await putWireguardPeer(client, peerToRosBody(body))
await recordWireguard(server, "apply")
return reply.status(201).send({ ok: true })
} catch (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,
}),
)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -291,6 +327,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -320,6 +357,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
const applied = await applyParsedConfig(client, config)
await recordWireguard(server, "copy")
return reply.send({ dryRun: false, preview, applied })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -437,6 +475,46 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
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
@@ -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,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,
firewallRestPath,
} from "./mikrotik.js"
import {
captureAndAppendRevision,
} from "./config-revisions.js"
import type {
FirewallFamily,
FirewallTable,
RosFirewallAddressList,
RosFirewallFilter,
} from "../types/server.js"
import {
canonicalFirewallSnapshot,
type FirewallLiveList,
type FirewallLiveRule,
type FirewallSnapshot,
} from "./entity-snapshots.js"
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[]
addressLists: FirewallAddressListDto[]
liveRules: FirewallLiveRule[]
liveLists: FirewallLiveList[]
snapshot: FirewallSnapshot
}> {
const client = MikrotikClient.fromServer(server)
const ruleJobs = FAMILIES.flatMap((family) =>
TABLES.map(async (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 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([
Promise.all(ruleJobs),
Promise.all(listJobs),
])
return {
rules: ruleChunks.flat(),
addressLists: listChunks.flat(),
const rules: FirewallRuleDto[] = []
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<{
@@ -183,7 +284,14 @@ export async function listFirewallAll(): Promise<{
const perServer = await Promise.all(
allServers.map(async (server) => {
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 {
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 }
}
+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)
}
}
@@ -388,8 +388,8 @@ try {
assert.equal(def.excludeOverlayApplied, true)
assert.equal(def.excludeMeshApplied, true)
assert.ok(!def.conversationsList.some((r) => r.proto === 47))
assert.equal(def.conversationsList[0]?.service, "Google")
assert.equal(def.conversationsList[0]?.category, "Веб")
assert.equal(def.conversationsList[0]?.service, "YouTube")
assert.equal(def.conversationsList[0]?.category, "Видео / стриминг")
assert.equal(def.conversationsList[0]?.clientName, "Alice")
assert.equal(def.conversationsList[0]?.enName, "NSK-SERVHOST-RTK")
assert.equal(def.conversationsList[0]?.plane, "payload")
@@ -445,8 +445,8 @@ try {
const rev = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
assert.equal(google?.service, "Google")
assert.equal(google?.category, "Веб")
assert.equal(google?.service, "YouTube")
assert.equal(google?.category, "Видео / стриминг")
assert.equal(cf?.service, "Cloudflare")
assert.equal(cf?.category, "CDN")
} finally {
@@ -260,6 +260,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
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)
@@ -32,6 +32,8 @@ assert.equal(brandByAsn(401115)?.service, "ChatGPT")
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
assert.equal(lookupBrand("104.18.35.51", 0)?.service, "Cloudflare")
assert.equal(lookupBrand("173.194.151.65", 0)?.service, "Google")
assert.equal(lookupBrand("64.233.161.1", 0)?.service, "Google")
assert.equal(lookupBrand("142.250.1.10", 0)?.service, "Google")
assert.equal(lookupBrand("8.8.8.8", 0)?.service, "Google")
assert.equal(lookupBrand("203.0.113.9", 64500), null)
assert.equal(OTHER_SERVICE, "Прочее")
@@ -41,6 +43,7 @@ assert.equal(isNamedInternetService("GRE", "Туннель"), false)
assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(mapServiceNodeId("Прочее"), "svc:other")
assert.equal(brandByAsn(714)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN")
@@ -70,6 +73,12 @@ assert.equal(isSteamGamePort(6, 443, 50000), false)
assert.equal(resolveFlowBrand("104.18.35.51", 32590, "VALVE-CORPORATION", 6, 443, 1)?.service, "Cloudflare")
assert.equal(resolveFlowBrand("203.0.113.9", 32590, "", 17, 27015, 50000)?.service, "Steam")
assert.equal(resolveFlowBrand("8.8.8.8", 15169, "GOOGLE", 6, 443, 51234)?.service, "Google")
assert.equal(resolveFlowBrand("173.194.160.163", 15169, "GOOGLE", 6, 443, 51234)?.service, "YouTube")
assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 17, 443, 50000)?.service, "YouTube")
assert.equal(resolveFlowBrand("64.233.161.1", 0, "", 6, 80, 50000)?.service, "Google")
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 53, 53000)?.service, "Google")
assert.equal(resolveFlowBrand("2001:4860:4860::8888", 15169, "GOOGLE", 17, 443, 50000)?.service, "YouTube")
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
+30 -3
View File
@@ -174,6 +174,14 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "172.217.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "74.125.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "142.250.0.0/15", prefixLen: 15, hit: GOOGLE },
{ cidr: "64.233.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "66.102.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "66.249.64.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "72.14.192.0/18", prefixLen: 18, hit: GOOGLE },
{ cidr: "108.177.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "209.85.128.0/17", prefixLen: 17, hit: GOOGLE },
{ cidr: "216.58.192.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "216.239.32.0/19", prefixLen: 19, hit: GOOGLE },
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
].sort((a, b) => b.prefixLen - a.prefixLen)
@@ -196,6 +204,16 @@ const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
const STEAM_ASN = 32590
const GOOGLE_FRONT_ASN = new Set([15169, 396982])
function isGooglePublicDns(ip: string): boolean {
return ipInCidrV4(ip, "8.8.8.0/24") || ipInCidrV4(ip, "8.8.4.0/24")
}
function isHttpsOrQuic(proto: number, dstPort: number, srcPort: number): boolean {
if (proto !== 6 && proto !== 17) return false
return dstPort === 443 || srcPort === 443
}
export function isIsoCountry(code: string): boolean {
const c = String(code ?? "").trim().toUpperCase()
@@ -273,7 +291,15 @@ export function resolveFlowBrand(
if (cidrBrand?.service === "Cloudflare") return cidrBrand
const holderBrand = brandByHolder(holder)
if (holderBrand) return holderBrand
const fromLookup = cidrBrand || brandByAsn(asn)
const asnBrand = brandByAsn(asn)
if (
!isGooglePublicDns(ip)
&& isHttpsOrQuic(proto, dstPort, srcPort)
&& (GOOGLE_FRONT_ASN.has(asn) || cidrBrand?.service === "Google" || asnBrand?.service === "Google")
) {
return YOUTUBE
}
const fromLookup = cidrBrand || asnBrand
if (fromLookup) return fromLookup
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
return null
@@ -300,8 +326,9 @@ export function isNamedInternetService(service: string, category: string): boole
}
export function mapServiceNodeId(label: string): string {
const slug = label
.trim()
const raw = label.trim()
if (raw === OTHER_SERVICE) return "svc:other"
const slug = raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
@@ -33,10 +33,10 @@ const google = classifyFlowDst("173.194.160.163", 6, 443, 1, {
ok: true,
fetchedAt: Date.now(),
})
assert.equal(google.service, "Google")
assert.equal(google.category, "Веб")
assert.equal(google.service, "YouTube")
assert.equal(google.category, "Видео / стриминг")
const googleCidr = classifyFlowDst("173.194.151.65", 6, 57182, 443, null)
const googleCidr = classifyFlowDst("173.194.151.65", 6, 80, 50000, null)
assert.equal(googleCidr.service, "Google")
assert.equal(googleCidr.category, "Веб")
@@ -115,12 +115,23 @@ const googleCloud = classifyFlowDst("203.0.113.43", 6, 443, 1, {
ok: true,
fetchedAt: Date.now(),
})
assert.equal(googleCloud.service, "Google")
assert.equal(googleCloud.category, "Веб")
assert.equal(googleCloud.service, "YouTube")
assert.equal(googleCloud.category, "Видео / стриминг")
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
assert.equal(gre.service, "GRE")
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)
assert.equal(esp.category, "Туннель")
assert.equal(applicationName(17, 443, 50000), "QUIC")
@@ -73,9 +73,12 @@ export function classifyFlowDst(
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
opts?: { ignoreTunnelProto?: boolean },
): FlowClassification {
if (proto === 47) return { service: "GRE", category: "Туннель" }
if (proto === 50) return { service: "ESP", category: "Туннель" }
if (!opts?.ignoreTunnelProto) {
if (proto === 47) return { service: "GRE", category: "Туннель" }
if (proto === 50) return { service: "ESP", category: "Туннель" }
}
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
const hit = matchCidr(dst)
+53 -6
View File
@@ -4,6 +4,7 @@ import {
resetEngineForTests,
} from "./traffic-flow-engine.js"
import { factsSnapshotForTests } from "./traffic-flow-facts.js"
import { classifyInternetBrand, mapInternetBrand } from "./traffic-flow-dest.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
disableRipeEnqueueForTests,
@@ -88,19 +89,65 @@ ingestParsedFlowsForServerForTests(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)
const asnBytes = facts.reduce((s, r) => s + r.bytes, 0)
assert.equal(total, 150)
assert.equal(asnBytes, 150, "unique bytes = SUM dest ASN")
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, 50)
const other = facts.filter((r) => r.asn === 0).reduce((s, r) => s + r.bytes, 0)
assert.equal(other, 100)
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")
assert.equal(mapInternetBrand("203.0.113.50", 6, 443, 51234, null).service, "Прочее")
assert.equal(mapInternetBrand("8.8.8.8", 47, 0, 0, null).service, "Прочее")
assert.equal(mapInternetBrand("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)
+56 -4
View File
@@ -1,4 +1,5 @@
import { isIsoCountry } from "./traffic-flow-brands.js"
import { applicationName } from "./traffic-flow-apps.js"
import { isIsoCountry, isNamedInternetService, OTHER_SERVICE, 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"
@@ -22,14 +23,54 @@ 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)),
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
}
const OTHER_BRAND: FlowClassification = { service: OTHER_SERVICE, category: OTHER_SERVICE }
/** Бренд для карты: именованный сервис или «Прочее» (GRE/ESP не сервис). */
export function mapInternetBrand(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): FlowClassification {
if (proto === 47 || proto === 50) return OTHER_BRAND
return classifyInternetBrand(dst, proto, dstPort, srcPort, ripe) ?? OTHER_BRAND
}
export function resolveInternetDest(opts: {
src: string
dst: string
@@ -39,16 +80,27 @@ export function resolveInternetDest(opts: {
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),
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 = classifyFlowDst(dest || opts.dst, opts.proto, opts.dstPort, opts.srcPort, ripe)
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 }
}
+67 -7
View File
@@ -61,6 +61,10 @@ export interface PendingFlowRow {
nextHop: string
flowStartMs: number
flowEndMs: number
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
}
function inetOrNull(value: string | null | undefined): string | null {
@@ -90,6 +94,17 @@ function clampProto(n: number): number {
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 {
const src = (r.src || "").trim() || "0.0.0.0"
const dst = (r.dst || "").trim() || "0.0.0.0"
@@ -101,6 +116,10 @@ function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
dst,
nextHop: next && isValidFlowInet(next) ? next : "",
proto: clampProto(r.proto),
natSrc: sanitizeNatIp(r.natSrc),
natDst: sanitizeNatIp(r.natDst),
natSrcPort: clampPort(r.natSrcPort),
natDstPort: clampPort(r.natDstPort),
}
}
@@ -120,6 +139,10 @@ function flowUpsertParams(r: PendingFlowRow) {
nextHop: inetOrNull(r.nextHop),
flowStartMs: r.flowStartMs,
flowEndMs: r.flowEndMs,
natSrc: inetOrNull(r.natSrc),
natDst: inetOrNull(r.natDst),
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
}
}
@@ -360,6 +383,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
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)
@@ -385,6 +412,11 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
src: flow.src,
dst: flow.dst,
topo,
dest: destMeta.dest,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
})) {
bumpFlowFact({
serverId,
@@ -405,6 +437,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
prev.packets += flow.packets
if (flow.outIface && !prev.flow.outIface) prev.flow.outIface = flow.outIface
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)) {
prev.flow.flowStartMs = flow.flowStartMs
}
@@ -460,6 +496,10 @@ function toPendingRow(row: PendingEntry): PendingFlowRow {
nextHop: flow.nextHop,
flowStartMs: flow.flowStartMs,
flowEndMs: flow.flowEndMs,
natSrc: flow.natSrc,
natDst: flow.natDst,
natSrcPort: flow.natSrcPort,
natDstPort: flow.natDstPort,
}
}
@@ -471,6 +511,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
prev.packets += row.packets
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
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.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
return
@@ -779,7 +823,7 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
await pool.query({
text: `
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 *
FROM UNNEST(
@@ -796,8 +840,12 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
$11::text[],
$12::inet[],
$13::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)
$14::bigint[],
$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)
DO UPDATE SET
bytes = flow_buckets.bytes + excluded.bytes,
@@ -807,7 +855,11 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
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)
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: [
rows.map((r) => r.serverId),
@@ -824,15 +876,19 @@ async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
rows.map((r) => inetOrNull(r.nextHop)),
rows.map((r) => r.flowStartMs),
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 = `
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 (
@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)
DO UPDATE SET
@@ -843,7 +899,11 @@ const FLOW_UPSERT_SQL = `
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)
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> {
@@ -145,6 +145,63 @@ const enWan = shouldWriteFlowFact({
})
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")
@@ -1,8 +1,10 @@
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 { classifyFlowPlane } from "./traffic-flow-planes.js"
import { resolveClient, type FlowTopology } from "./traffic-flow-topology.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"])
@@ -82,6 +84,11 @@ export function shouldWriteFlowFact(opts: {
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
@@ -96,7 +103,25 @@ export function shouldWriteFlowFact(opts: {
inIface: inName,
outIface: outName || undefined,
}, opts.topo?.plane)
if (plane !== "payload") return false
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)
@@ -3,6 +3,7 @@ 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,
@@ -19,6 +20,8 @@ if (!(await withPgOrSkip())) {
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 (не пустая БД)")
@@ -119,10 +122,10 @@ try {
`, [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, 150)
assert.equal(total, 50)
assert.equal(byAsn.get(12389), undefined, "ASN клиента не в hour facts")
assert.equal(byAsn.get(15169), 50)
assert.equal(byAsn.get(0), 100)
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])
@@ -69,10 +69,16 @@ export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildRes
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"
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 ?
@@ -81,6 +87,20 @@ export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildRes
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,
@@ -92,17 +112,12 @@ export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildRes
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
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,
})
bumpFlowFact({
serverId: row.serverId,
bucketAt: hourFromBucket(row.bucketAt),
@@ -15,6 +15,7 @@ import {
lastFlushUsedTransactionForTests,
maybeRefreshIfaces,
peekPendingFlows,
capFlowRowsPerServerBucket,
resetFlowRingsForTests,
setPendingCapForTests,
setRefreshIfacesForTests,
@@ -146,4 +147,20 @@ resetFlowRingsForTests()
resetIfaceCacheForTests()
setRefreshIfacesForTests(null)
{
const minute0 = "2026-01-01T00:00:00.000Z"
const minute1 = "2026-01-01T00:01:00.000Z"
const rows: Array<{ serverId: number; bucketAt: string; bytes: number; id: string }> = []
for (let i = 1; i <= 25; i++) {
rows.push({ serverId: 1, bucketAt: minute0, bytes: i, id: `a${i}` })
rows.push({ serverId: 2, bucketAt: minute0, bytes: i, id: `b${i}` })
}
rows.push({ serverId: 1, bucketAt: minute1, bytes: 1, id: "a-min-other-minute" })
const capped = capFlowRowsPerServerBucket(rows, 20)
assert.equal(capped.filter((r) => r.serverId === 1 && r.bucketAt === minute0).length, 20)
assert.equal(capped.filter((r) => r.serverId === 2).length, 20)
assert.ok(capped.some((r) => r.id === "a-min-other-minute"), "другая минута не режется глобальным top-N")
assert.ok(!capped.some((r) => r.id === "a1" || r.id === "b1"), "мелкие 5-tuple сервера выпадают только в своём bucket")
}
console.log("traffic-flow-ingest.test.ts: ok")
+87 -21
View File
@@ -1,8 +1,7 @@
import { Worker } from "node:worker_threads"
import { gte, sql } from "drizzle-orm"
import { db, dbGet, dbQuery, pool, withAdvisoryLock } from "../db/index.js"
import { db, dbAll, dbGet, dbQuery, pool, withAdvisoryLock } from "../db/index.js"
import { dropExpiredPartitions } from "../db/partitions.js"
import { flowBuckets, servers } from "../db/schema.js"
import { servers } from "../db/schema.js"
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { protoName, type ParsedFlowInput } from "./traffic-flow-parse.js"
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
@@ -288,6 +287,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
prev.packets += row.packets
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
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.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
return
@@ -295,6 +298,32 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
map.set(key, { ...row })
}
/** Top-N разговоров на (server_id, bucket_at), как prune persist — не глобальный ORDER BY bytes. */
export function capFlowRowsPerServerBucket<T extends { serverId: number; bucketAt: string; bytes: number }>(
rows: T[],
keep: number,
): T[] {
const cap = Math.max(20, keep)
const groups = new Map<string, T[]>()
for (const row of rows) {
const k = `${row.serverId}\0${row.bucketAt}`
const list = groups.get(k)
if (list) list.push(row)
else groups.set(k, [row])
}
const out: T[] = []
for (const list of groups.values()) {
list.sort((a, b) => b.bytes - a.bytes)
out.push(...list.slice(0, cap))
}
return out
}
function isoBucketAt(v: unknown): string {
if (v instanceof Date) return v.toISOString()
return String(v ?? "")
}
export async function listLiveFlowRows(sinceIso: string): Promise<PendingFlowRow[]> {
if (worker && lastHeartbeat?.workerAlive) {
return await listStoredFlowRows(sinceIso)
@@ -302,30 +331,67 @@ export async function listLiveFlowRows(sinceIso: string): Promise<PendingFlowRow
return engineListLive(sinceIso)
}
interface StoredBucketRow {
server_id: number
bucket_at: string | Date
src: string
dst: string
proto: number
src_port: number
dst_port: number
bytes: number
packets: number
in_iface: string
out_iface: string | null
next_hop: string | null
flow_start_ms: number | null
flow_end_ms: number | null
nat_src: string | null
nat_dst: string | null
nat_src_port: number | null
nat_dst_port: number | null
}
export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowRow[]> {
const settings = await getTrafficFlowSettingsRow()
const cap = Math.max(20, settings.topN) * 60
const stored = await db.select().from(flowBuckets)
.where(gte(flowBuckets.bucketAt, sinceIso))
.orderBy(sql`${flowBuckets.bytes} DESC`)
.limit(cap)
const keep = Math.max(20, settings.topN)
const stored = await dbAll<StoredBucketRow>(`
SELECT
server_id, bucket_at, src::text AS src, dst::text AS dst, proto,
src_port, dst_port, bytes, packets, in_iface, out_iface,
next_hop::text AS next_hop, flow_start_ms, flow_end_ms,
nat_src::text AS nat_src, nat_dst::text AS nat_dst, nat_src_port, nat_dst_port
FROM (
SELECT fb.*,
ROW_NUMBER() OVER (
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
) AS rn
FROM flow_buckets fb
WHERE bucket_at >= $1
) ranked
WHERE rn <= $2
`, [sinceIso, keep])
const merged = new Map<string, PendingFlowRow>()
for (const r of stored) {
mergeInto(merged, {
serverId: r.serverId,
bucketAt: r.bucketAt,
serverId: Number(r.server_id),
bucketAt: isoBucketAt(r.bucket_at),
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
outIface: r.outIface ?? "",
nextHop: r.nextHop ?? "",
flowStartMs: r.flowStartMs ?? 0,
flowEndMs: r.flowEndMs ?? 0,
proto: Number(r.proto) || 0,
srcPort: Number(r.src_port) || 0,
dstPort: Number(r.dst_port) || 0,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
inIface: r.in_iface ?? "",
outIface: r.out_iface ?? "",
nextHop: r.next_hop ?? "",
flowStartMs: Number(r.flow_start_ms) || 0,
flowEndMs: Number(r.flow_end_ms) || 0,
natSrc: r.nat_src ?? "",
natDst: r.nat_dst ?? "",
natSrcPort: Number(r.nat_src_port) || 0,
natDstPort: Number(r.nat_dst_port) || 0,
})
}
if (!worker) {
@@ -334,7 +400,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
mergeInto(merged, p)
}
}
return [...merged.values()]
return capFlowRowsPerServerBucket([...merged.values()], keep)
}
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
@@ -56,5 +56,27 @@ assert.equal(
"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")
+46 -20
View File
@@ -55,54 +55,80 @@ export function isNonPublicIp(ip: string): boolean {
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 (isNonPublicIp(ip)) return true
if (isUnspecifiedIp(ip) || isNonPublicIp(ip)) return true
return Boolean(ours?.has(String(ip ?? "").trim()))
}
/**
* Интернет-назначение потока для ASN/страны/сервиса.
* Пустая строка dest нет (не GeoIP IP клиента).
* Пустая строка dest нет (не GeoIP IP клиента / GRE-пира).
*/
export function pickInternetDest(
src: string,
dst: string,
srcRaw: string,
dstRaw: string,
srcPort: number,
dstPort: number,
ctx?: InternetDestCtx,
): string {
const ours = ctx?.ours
const srcLocal = isLocalIp(src, ours)
const dstLocal = isLocalIp(dst, ours)
const srcPub = !srcLocal
const dstPub = !dstLocal
const src = usableIp(srcRaw)
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 (dstPub) return dst
if (srcPub && dstLocal) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
if (srcWk && !dstWk) return src
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 (srcPub && !dstPub) return src
if (dstPub && !srcPub) return dst
if (srcPub && dstPub) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
if (srcWk && !dstWk) return src
if (dstWk && !srcWk) return dst
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
}
@@ -70,6 +70,21 @@ import {
console.log("traffic-flow-map-hops.test.ts: pickMapServices ok")
}
{
const leak = pickMapServices(
[
{ id: "svc:other", label: "Прочее", category: "Прочее", bytes: 19_000, bps: 0, share: 19 / 30 },
{ id: "svc:google", label: "Google", category: "Веб", bytes: 11_000, bps: 0, share: 11 / 30 },
],
5,
)
assert.equal(leak.reduce((n, s) => n + s.bytes, 0), 30_000)
const google = leak.find((s) => s.id === "svc:google")
assert.ok(google)
assert.ok(google.share < 0.4, "доля от окна хопа, не от named-only")
assert.ok(leak.some((s) => s.id === "svc:other"), "дыра видна как Прочее")
}
if (!(await withPgOrSkip())) {
console.log("traffic-flow-map-hops.test.ts: skip")
process.exit(0)
@@ -279,12 +294,18 @@ try {
resetFlowMapHopsCacheForTests()
const six = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(six.totalBytes, 10_000)
assert.equal(six.namedBytes, 600)
assert.equal(six.unclassifiedBytes, 9400)
const google = six.services?.find((s) => s.id === "svc:google")
const otherSix = six.services?.find((s) => s.id === "svc:other")
assert.ok(google, "Google ≥ 5%")
assert.ok(google.share >= 0.05)
assert.ok(otherSix, "остаток — Прочее")
assert.ok(google.share >= 0.05 && google.share < 0.1)
assert.ok(otherSix.share >= 0.9)
const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice")
assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -309,9 +330,14 @@ try {
resetFlowMapHopsCacheForTests()
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(four.totalBytes, 10_000)
assert.equal(four.namedBytes, 400)
assert.equal(four.unclassifiedBytes, 9600)
const googleFour = four.services?.find((s) => s.id === "svc:google")
assert.ok(googleFour, "единственный бренд виден при 4% от окна")
assert.ok(googleFour.share >= 0.99, "доля среди брендов ≈ 1")
const otherFour = four.services?.find((s) => s.id === "svc:other")
assert.ok(googleFour, "бренд виден при 4% от окна (MIN_NODES)")
assert.ok(otherFour, "Прочее держит остаток окна")
assert.ok(googleFour.share < 0.1, "доля от totalBytes, не от named")
assert.ok(otherFour.share > 0.9)
resetFlowMapHopsCacheForTests()
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google")
@@ -340,10 +366,13 @@ try {
const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const googleTwo = two.services?.find((s) => s.id === "svc:google")
const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare")
const otherTwo = two.services?.find((s) => s.id === "svc:other")
assert.ok(googleTwo, "Google среди брендов")
assert.ok(cfTwo, "Cloudflare среди брендов")
assert.ok(googleTwo.share >= 0.05)
assert.ok(cfTwo.share >= 0.05)
assert.ok(otherTwo, "Прочее")
assert.ok(googleTwo.share > 0.03 && googleTwo.share < 0.05)
assert.ok(cfTwo.share > 0.03 && cfTwo.share < 0.05)
assert.ok(otherTwo.share > 0.9)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
@@ -373,6 +402,43 @@ try {
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
payloadFlow("64.233.161.1", 10_000),
payloadFlow("203.0.113.50", 20_000),
])
try {
resetFlowMapHopsCacheForTests()
const leak = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const hop = leak.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
assert.ok(hop, "GRE hop JH→EN")
assert.equal(hop.bytes, 30_000)
assert.equal(leak.totalBytes, 30_000)
assert.equal(leak.namedBytes, 10_000)
assert.equal(leak.unclassifiedBytes, 20_000)
const yt = leak.services?.find((s) => s.id === "svc:youtube")
const other = leak.services?.find((s) => s.id === "svc:other")
assert.ok(yt, "64.233:443 без RIPE → YouTube")
assert.ok(other, "dest без бренда → Прочее")
assert.equal(yt.bytes, 10_000)
assert.equal(other.bytes, 20_000)
assert.ok(Math.abs(yt.share - 10 / 30) < 0.01)
assert.ok(Math.abs(other.share - 20 / 30) < 0.01)
assert.equal((leak.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), hop.bytes)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [
{ ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" },
{ ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" },
@@ -450,6 +516,72 @@ try {
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()
resetIfaceCacheForTests()
resetRipeCacheForTests()
@@ -488,9 +620,9 @@ ingestParsedFlowsForServerForTests(7, [
try {
resetFlowMapHopsCacheForTests()
const rev = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(rev.services?.some((s) => s.id === "svc:google"), "реверс Google:443 → 10.x")
assert.ok(rev.services?.some((s) => s.id === "svc:youtube"), "реверс googlevideo:443 → YouTube")
assert.ok(rev.services?.some((s) => s.id === "svc:cloudflare"), "реверс Cloudflare:443 → 10.x")
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:youtube" && e.fromId === "9"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
@@ -566,13 +698,13 @@ ingestParsedFlowsForServerForTests(7, [
try {
resetFlowMapHopsCacheForTests()
const wanOnly = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(googleEdge, "Google WAN без GRE payload")
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:youtube")
assert.ok(googleEdge, "YouTube WAN без GRE payload")
assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop")
assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис")
assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:google")
assert.ok(googlePath, "путь WAN Google")
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:youtube")
assert.ok(googlePath, "путь WAN YouTube")
assert.equal(googlePath.viaId, "7", "via = JH exporter")
assert.equal(googlePath.enId, "9", "якорь EN")
assert.ok(googlePath.bps > 0, "скорость на пути клиента")
+25 -34
View File
@@ -2,19 +2,14 @@ import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js"
import { userInterfaceBindings } from "../db/schema.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import {
isNamedInternetService,
mapServiceNodeId,
resolveFlowBrand,
} from "./traffic-flow-brands.js"
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { OTHER_SERVICE, mapServiceNodeId } from "./traffic-flow-brands.js"
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { destCtxForIface } from "./traffic-flow-dest.js"
import { destCtxForIface, mapInternetBrand } from "./traffic-flow-dest.js"
import { pickInternetDest } from "./traffic-flow-ip.js"
import { type FlowIpMeta } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog, type FlowTopology } from "./traffic-flow-topology.js"
@@ -103,7 +98,7 @@ export function clampMapServiceMinSharePct(n: unknown): number {
return Math.min(100, Math.max(0, v))
}
/** Доля среди именованных брендов; порог ИЛИ топ-N, затем cap. */
/** Доля от payload окна; порог ИЛИ топ-N, затем cap. */
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
const minShare = minSharePct / 100
@@ -186,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> {
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
try {
@@ -360,11 +339,16 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
r.dst,
r.srcPort,
r.dstPort,
destCtxForIface(topo, r.serverId, inName),
destCtxForIface(topo, r.serverId, inName, {
natSrc: r.natSrc,
natDst: r.natDst,
natSrcPort: r.natSrcPort,
natDstPort: r.natDstPort,
}),
)
if (!dest) continue
const destKey = dest || "__other__"
const client = resolveMapClient(topo, r.serverId, inName, outName)
const prevDst = dstAcc.get(dest)
const prevDst = dstAcc.get(destKey)
if (prevDst) {
prevDst.bytes += r.bytes
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
@@ -377,7 +361,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
fromBytes: new Map(),
}
bumpFrom(acc, String(r.serverId), r.bytes, client)
dstAcc.set(dest, acc)
dstAcc.set(destKey, acc)
}
}
@@ -432,9 +416,10 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
for (const [dst, acc] of dstAcc) {
const ripe = resolveFlowIp(dst)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
if (!classified) continue
const ripe = dst && dst !== "__other__" ? resolveFlowIp(dst) : null
const classified = dst && dst !== "__other__"
? mapInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
: { service: OTHER_SERVICE, category: OTHER_SERVICE }
const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId)
if (prevSvc) prevSvc.bytes += acc.bytes
@@ -490,7 +475,11 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
}
const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0)
const namedBytes = [...svcTotals.values()]
.filter((s) => s.label !== OTHER_SERVICE)
.reduce((n, s) => n + s.bytes, 0)
const unclassifiedBytes = Math.max(0, totalBytes - namedBytes)
const shareBase = totalBytes > 0 ? totalBytes : namedBytes
const services = pickMapServices(
[...svcTotals.entries()]
.map(([id, s]) => ({
@@ -499,7 +488,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: namedBytes > 0 ? s.bytes / namedBytes : 0,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
@@ -547,6 +536,8 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
rangeMinutes: q.minutes,
windowSec,
totalBytes,
namedBytes,
unclassifiedBytes,
services,
serviceEdges,
servicePaths,
+38 -2
View File
@@ -114,6 +114,8 @@ async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
"last-forwarded": "yes",
"nat-src-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 id = rows[0] ? rosRowId(rows[0]) : ""
@@ -124,6 +126,9 @@ async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
await client.post("/ip/traffic-flow/ipfix/set", body)
}
/** Максимум flows в RAM (docs: overflow обрезает новые 5-tuple). */
export const FLOW_CACHE_ENTRIES = "256k"
async function ensureTrafficFlow(
client: MikrotikClient,
collectorIp: string,
@@ -132,6 +137,7 @@ async function ensureTrafficFlow(
const body = toRosBody({
enabled: "yes",
interfaces: "all",
"cache-entries": FLOW_CACHE_ENTRIES,
"active-flow-timeout": "1m",
"inactive-flow-timeout": "15s",
})
@@ -165,6 +171,23 @@ async function ensureTrafficFlow(
await client.put("/ip/traffic-flow/target", targetBody)
}
/** GRE allow-fast-path=no: inner пакеты идут через CPU и попадают в Traffic Flow. Нагрузка на CPU. */
export async function ensureGreSlowPath(client: MikrotikClient): Promise<number> {
const rows = asRosArray<Record<string, unknown>>(await client.get("/interface/gre"))
let patched = 0
for (const row of rows) {
const id = rosRowId(row)
if (!id) continue
const current = String(row["allow-fast-path"] ?? "true").toLowerCase()
if (current === "false" || current === "no") continue
await patchRosPath(client, `/interface/gre/${encodeRosId(id)}`, toRosBody({
"allow-fast-path": "no",
}))
patched += 1
}
return patched
}
export function usablePublicHost(raw: string | undefined): string {
if (!raw) return ""
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
@@ -178,7 +201,7 @@ export function usablePublicHost(raw: string | undefined): string {
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string },
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
const keys = await ensureHostKeys()
@@ -273,7 +296,20 @@ export async function applyFlowOverlay(
}
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto, cache ${FLOW_CACHE_ENTRIES})`)
if (opts?.disableGreFastPath) {
try {
const n = await ensureGreSlowPath(client)
steps.push(
n > 0
? `GRE allow-fast-path=no (${n}) — inner IPFIX через CPU`
: "GRE already allow-fast-path=no",
)
} catch {
steps.push("GRE allow-fast-path не изменён (нет /interface/gre)")
}
}
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { parseFlowPacket, protoName, resetFlowTemplatesForTests, templateExporterCountForTests } from "./traffic-flow-parse.js"
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
import { allocateOverlayAddress, FLOW_CACHE_ENTRIES, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
function netflowV5One(): Buffer {
const buf = Buffer.alloc(24 + 48)
@@ -38,6 +38,7 @@ assert.equal(usablePublicHost("192.168.1.10"), "")
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0")
assert.equal(FLOW_CACHE_ENTRIES, "256k")
resetFlowTemplatesForTests()
{
@@ -182,6 +183,95 @@ resetFlowTemplatesForTests()
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()
{
const tpl = Buffer.alloc(16 + 16 + 20)
+23 -3
View File
@@ -13,6 +13,8 @@ export interface ParsedFlow {
flowEndMs: number
natSrc: string
natDst: string
natSrcPort: number
natDstPort: number
}
export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes">
@@ -33,6 +35,8 @@ export function emptyParsedFlow(): ParsedFlow {
flowEndMs: 0,
natSrc: "",
natDst: "",
natSrcPort: 0,
natDstPort: 0,
}
}
@@ -45,6 +49,8 @@ export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
flowEndMs: flow.flowEndMs ?? 0,
natSrc: flow.natSrc ?? "",
natDst: flow.natDst ?? "",
natSrcPort: flow.natSrcPort ?? 0,
natDstPort: flow.natDstPort ?? 0,
inIface: flow.inIface ?? "",
outIface: flow.outIface ?? "",
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]}`
}
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 {
const parts: string[] = []
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 natSrc = ""
let natDst = ""
let natSrcPort = 0
let natDstPort = 0
for (const f of fields) {
const field = consumeField(buf, off, f.length, limit)
if (!field) return null
@@ -243,15 +257,21 @@ function recordFromFields(
case 225:
if (data.length === 4) {
natSrc = ipv4(data, 0)
if (!src) src = natSrc
if (!usableIpfixIp(src) && usableIpfixIp(natSrc)) src = natSrc
}
break
case 226:
if (data.length === 4) {
natDst = ipv4(data, 0)
if (!dst) dst = natDst
if (!usableIpfixIp(dst) && usableIpfixIp(natDst)) dst = natDst
}
break
case 227:
natSrcPort = readUint(data, 0, data.length)
break
case 228:
natDstPort = readUint(data, 0, data.length)
break
case 4:
proto = readUint(data, 0, data.length)
break
@@ -308,7 +328,7 @@ function recordFromFields(
if (ifaceName && !inIface) inIface = ifaceName
return {
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,
}
+87
View File
@@ -3,6 +3,13 @@ import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
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
@@ -35,6 +42,7 @@ interface RosWireGuardPeer {
"client-address"?: string
"client-dns"?: string
"client-endpoint"?: string
"private-key"?: string
}
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 = {
interfaces: WgIfaceDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
+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 { cn } from "@/lib/utils"
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 {
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 {
AlertCircleIcon,
AlertTriangleIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUpIcon,
CircleDashedIcon,
PencilIcon,
RouteIcon,
StarIcon,
TrashIcon,
XCircleIcon,
FilterIcon,
} from "lucide-react"
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
export interface RecursiveRouteLite {
id: string
dstAddress: string
@@ -47,42 +40,6 @@ export interface RecursiveRouteLite {
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) {
return ip.split("/")[0]
}
@@ -234,8 +191,6 @@ interface FiltersDataGridProps {
serversList: Server[]
communityNameMap: Record<string, string>
recursiveRoutes: RecursiveRouteLite[]
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
isLive?: boolean
enableSorting?: boolean
onEdit: (rule: FilterRule) => void
onDelete: (id: string) => void
@@ -249,8 +204,6 @@ function FiltersDataGrid({
serversList,
communityNameMap,
recursiveRoutes,
routerSyncByCommunity,
isLive,
enableSorting = false,
onEdit,
onDelete,
@@ -308,33 +261,6 @@ function FiltersDataGrid({
size: 28,
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",
accessorKey: "community",
@@ -413,13 +339,11 @@ function FiltersDataGrid({
[
communityNameMap,
enableSorting,
isLive,
onDelete,
onEdit,
onMoveDown,
onMoveUp,
recursiveRoutes,
routerSyncByCommunity,
serversList,
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 }) {
const s = STATUS_MAP[status]
const s = STATUS_MAP[status] ?? STATUS_MAP.degraded
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={cn("size-1.5 rounded-full", s.dot)} />
@@ -101,6 +101,10 @@ interface GreTunnelsDataGridProps {
servers: Server[]
pools: GrePool[]
onCodePreview: (tunnel: GreTunnel) => void
onEdit?: (tunnel: GreTunnel) => void
onToggle?: (tunnel: GreTunnel) => void
onDelete?: (tunnel: GreTunnel) => void
mutationsLocked?: boolean
}
function GreTunnelsDataGrid({
@@ -108,6 +112,10 @@ function GreTunnelsDataGrid({
servers,
pools,
onCodePreview,
onEdit,
onToggle,
onDelete,
mutationsLocked = false,
}: GreTunnelsDataGridProps) {
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
@@ -200,14 +208,21 @@ function GreTunnelsDataGrid({
cell: ({ row }) => {
const t = row.original
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 (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}
{[enc, auth].filter(Boolean).join(" / ") || "PSK"}
</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}
{t.ipsec.pfs && " · PFS"}
{[dh?.split(" ")[0], ike].filter(Boolean).join(" · ")}
{t.ipsec.pfs ? " · PFS" : ""}
</span>
</div>
)
@@ -297,16 +312,20 @@ function GreTunnelsDataGrid({
<DropdownMenuItem onClick={() => onCodePreview(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem disabled={mutationsLocked || !onEdit} onClick={() => onEdit?.(t)}>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem disabled={mutationsLocked || !onToggle} onClick={() => onToggle?.(t)}>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<DropdownMenuItem
variant="destructive"
disabled={mutationsLocked || !onDelete}
onClick={() => onDelete?.(t)}
>
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
@@ -318,7 +337,7 @@ function GreTunnelsDataGrid({
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({
+15 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField } from "@/components/form-kit"
import { FormField, FormToggle } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
@@ -54,12 +54,14 @@ function FlowOverlaySheet({
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
const [copied, setCopied] = useState(false)
const [tab, setTab] = useState("linux")
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
useEffect(() => {
if (!open) return
setResult(null)
setCopied(false)
setTab("linux")
setDisableGreFastPath(false)
const first = jumpHosts[0]
const nextId = first ? String(first.id) : ""
setServerId(nextId)
@@ -84,7 +86,9 @@ function FlowOverlaySheet({
if (!serverId || !endpoint.trim()) return
setBusy(true)
try {
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim(), {
disableGreFastPath,
})
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
@@ -151,6 +155,15 @@ function FlowOverlaySheet({
autoComplete="off"
/>
</FormField>
<FormField
label="GRE slow-path (IPFIX inner)"
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
>
<div className="flex items-center gap-2">
<FormToggle checked={disableGreFastPath} onChange={setDisableGreFastPath} />
<span className="text-sm text-muted-foreground">Выключить FastPath на GRE</span>
</div>
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
+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
}
+7 -6
View File
@@ -531,12 +531,13 @@ export type DscpMode = "inherit" | number
export interface GreIpsec {
secret: string // ipsec-secret → auto-creates peer+policy+proposal
encAlg: IpsecEncAlg // proposal enc-algorithms
authAlg: IpsecAuthAlg // proposal auth-algorithms
dhGroup: IpsecDhGroup // proposal pfs-group / peer dh-group
ikeVersion: IkeVersion // peer exchange-mode
lifetime: string // proposal lifetime (e.g. "1d 00:00:00")
pfs: boolean // perfect forward secrecy
/** Live CHR отдаёт только secret; proposal-поля есть у моков / формы */
encAlg?: IpsecEncAlg
authAlg?: IpsecAuthAlg
dhGroup?: IpsecDhGroup
ikeVersion?: IkeVersion
lifetime?: string
pfs?: boolean
}
export interface GreTunnel {
+4
View File
@@ -47,6 +47,8 @@ export const trafficFlowSettingsPatchSchema = z.object({
export const trafficFlowOverlayRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
publicEndpoint: z.string().optional(),
/** GRE allow-fast-path=no: inner IPFIX через CPU. По умолчанию выкл (нагрузка на CPU). */
disableGreFastPath: z.boolean().optional(),
})
export const trafficFlowHostFileSchema = z.object({
@@ -316,6 +318,8 @@ export const flowMapHopsDtoSchema = z.object({
rangeMinutes: z.number().int().positive(),
windowSec: z.number().positive(),
totalBytes: z.number().nonnegative().optional(),
namedBytes: z.number().nonnegative().optional(),
unclassifiedBytes: z.number().nonnegative().optional(),
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
+6 -1
View File
@@ -46,10 +46,15 @@ export async function applyTrafficFlowOverlay(
baseUrl: string,
serverId: string | number,
publicEndpoint?: string,
opts?: { disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({ serverId, publicEndpoint }),
body: JSON.stringify({
serverId,
publicEndpoint,
...(opts?.disableGreFastPath ? { disableGreFastPath: true } : {}),
}),
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long