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 <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-09-11 14:03:00 +07:00
co-authored by Cursor
parent b4a3c3a925
commit 25b82997b6
22 changed files with 2232 additions and 41 deletions
+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}
/>
</>
)
}
+281 -12
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"
@@ -164,6 +176,7 @@ interface BackendServer {
interface GreTunnelsApiResponse {
tunnels: GreTunnel[]
failures?: Array<{ serverId: string; serverName?: string; error: string }>
}
function makeApiFetch(backendUrl: string) {
@@ -244,6 +257,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)
@@ -260,14 +282,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)
}
@@ -279,6 +306,7 @@ export default function GrePage() {
setLiveServers([])
setLiveTunnels([])
setDataError(null)
setLiveStale(false)
})
return
}
@@ -322,6 +350,187 @@ export default function GrePage() {
[displayPools],
)
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
}
if (liveStale) {
toast.error("Роутер недоступен — изменения заблокированы")
return
}
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)
}, [dataError])
@@ -368,6 +577,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-туннели" }]}
@@ -384,7 +601,20 @@ export default function GrePage() {
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<Button
variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<HistoryIcon className="size-4" />
История
</Button>
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
@@ -476,6 +706,10 @@ export default function GrePage() {
servers={displayServers}
pools={displayPools}
onCodePreview={setCodePreviewTunnel}
onEdit={openEditTunnel}
onToggle={(t) => { void toggleTunnel(t) }}
onDelete={setPendingDelete}
mutationsLocked={mutationsLocked}
/>
</DataPageCard>
)}
@@ -593,18 +827,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>)}
@@ -631,7 +865,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>
@@ -753,7 +987,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>
@@ -800,6 +1036,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>
</>
)
}
+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}
/>
</>
)
}
+1 -1
View File
@@ -19,7 +19,7 @@
"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 && tsx src/services/config-revisions.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.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 && npm run test:config-sync",
+2 -2
View File
@@ -96,10 +96,10 @@ export const filterRules = pgTable("filter_rules", {
export const configRevisions = pgTable("config_revisions", {
id: text("id").primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
section: text("section", { enum: ["filters", "recursive-routes"] }).notNull(),
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`),
payload: jsonb("payload").$type<unknown>().notNull().default(sql`'[]'::jsonb`),
note: text("note"),
createdAt: ts("created_at").notNull().defaultNow(),
}, (t) => [
+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",
},
]
+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
+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
@@ -59,6 +59,29 @@ try {
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,
+75 -8
View File
@@ -1,11 +1,19 @@
import { createHash, randomUUID } from "node:crypto"
import { and, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { configRevisions, type ConfigRevisionRow } from "../db/schema.js"
import { configRevisions, servers, type ConfigRevisionRow } from "../db/schema.js"
export const CONFIG_REVISION_KEEP = 50
export type ConfigSection = "filters" | "recursive-routes"
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 {
@@ -31,6 +39,23 @@ 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
@@ -78,17 +103,15 @@ export function canonicalRecursiveRoutes(
}
export function toRevisionDto(row: ConfigRevisionRow): ConfigRevisionDto {
const payload = row.payload
const itemCount = Array.isArray(payload) ? payload.length : 0
return {
id: row.id,
serverId: String(row.serverId),
section: row.section,
section: row.section as ConfigSection,
source: row.source,
fingerprint: row.fingerprint,
createdAt: row.createdAt,
note: row.note ?? null,
itemCount,
itemCount: revisionItemCount(row.payload),
}
}
@@ -135,7 +158,8 @@ export async function appendRevisionIfChanged(input: {
payload: unknown
note?: string | null
}): Promise<{ created: boolean; revision: ConfigRevisionDto }> {
const fingerprint = fingerprintPayload(input.payload)
const payload = persistablePayload(input.payload)
const fingerprint = fingerprintPayload(payload)
const latest = (await db
.select()
.from(configRevisions)
@@ -158,7 +182,7 @@ export async function appendRevisionIfChanged(input: {
section: input.section,
source: input.source,
fingerprint,
payload: Array.isArray(input.payload) ? input.payload : [],
payload,
note: input.note ?? null,
createdAt: now,
})
@@ -167,3 +191,46 @@ export async function appendRevisionIfChanged(input: {
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)
}
}
+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 }>
@@ -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])
@@ -297,16 +305,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 +330,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({
+1 -1
View File
@@ -3,7 +3,7 @@ export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
export interface ConfigRevisionDto {
id: string
serverId: string
section: "filters" | "recursive-routes"
section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre"
source: ConfigRevisionSource
fingerprint: string
createdAt: string
+1 -1
View File
File diff suppressed because one or more lines are too long