From 15ad53af1fc3caf4994674082a5caae906f0042b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 5 Sep 2026 02:10:55 +0700 Subject: [PATCH] feat(wireguard): implement WireGuard interface management and permissions Added comprehensive support for managing WireGuard interfaces, including CRUD operations and peer management. Updated permissions to include access control for WireGuard routes. Enhanced the UI components to display and interact with WireGuard configurations, improving user experience and functionality. Introduced new tests for WireGuard-related functionalities to ensure reliability. --- app/(main)/wireguard/page.tsx | 622 +++++++++++++----- backend/package.json | 3 +- backend/src/index.ts | 2 + backend/src/lib/permissions.test.ts | 8 + backend/src/lib/permissions.ts | 6 +- backend/src/routes/sidebar-counts.ts | 4 + backend/src/routes/wireguard.ts | 456 +++++++++++++ backend/src/services/wireguard-config.test.ts | 100 +++ backend/src/services/wireguard-config.ts | 359 ++++++++++ backend/src/services/wireguard-live.ts | 214 ++++++ components/app-sidebar.tsx | 5 +- components/data-grids/wireguard-data-grid.tsx | 70 +- .../data-grids/wireguard-peers-detail.tsx | 119 +++- components/wireguard/wg-create-sheet.tsx | 232 +++++++ components/wireguard/wg-export-sheet.tsx | 219 ++++++ components/wireguard/wg-import-sheet.tsx | 182 +++++ components/wireguard/wg-peer-sheet.tsx | 114 ++++ lib/data.ts | 12 + lib/sidebar-badges.ts | 2 + lib/wg-config.ts | 339 ++++++++++ packages/contracts/package.json | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/wireguard.ts | 171 +++++ shared/api/wireguard.ts | 107 +++ tsconfig.tsbuildinfo | 2 +- 25 files changed, 3122 insertions(+), 231 deletions(-) create mode 100644 backend/src/routes/wireguard.ts create mode 100644 backend/src/services/wireguard-config.test.ts create mode 100644 backend/src/services/wireguard-config.ts create mode 100644 backend/src/services/wireguard-live.ts create mode 100644 components/wireguard/wg-create-sheet.tsx create mode 100644 components/wireguard/wg-export-sheet.tsx create mode 100644 components/wireguard/wg-import-sheet.tsx create mode 100644 components/wireguard/wg-peer-sheet.tsx create mode 100644 lib/wg-config.ts create mode 100644 packages/contracts/src/wireguard.ts create mode 100644 shared/api/wireguard.ts diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index d9e5efd..2481e7a 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -1,9 +1,9 @@ "use client" -import { useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" -import { servers } from "@/lib/data" -import type { WireGuardInterface } from "@/lib/data" +import { servers as mockServers } from "@/lib/data" +import type { Server } from "@/lib/data" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { @@ -14,22 +14,32 @@ import { Button } from "@/components/ui/button" import { Frame, FramePanel } from "@/components/reui/frame" import { IconTile } from "@/components/reui/icon-tile" import { OpsPanel } from "@/components/ops-panel" -import { cn } from "@/lib/utils" +import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { - Sheet, SheetContent, SheetHeader, SheetTitle, - SheetDescription, SheetFooter, SheetClose, -} from "@/components/ui/sheet" + createWireGuardInterface, + createWireGuardPeer, + deleteWireGuardInterface, + deleteWireGuardPeer, + exportWireGuard, + importWireGuard, + listWireGuard, + patchWireGuardInterface, +} from "@/shared/api/wireguard" +import type { WgIfaceDto } from "@mmapp/contracts/wireguard" +import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg-create-sheet" +import { WgImportSheet } from "@/components/wireguard/wg-import-sheet" +import { WgExportSheet } from "@/components/wireguard/wg-export-sheet" +import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet" +import { toast } from "sonner" import { ShieldCheckIcon, PlusIcon, KeyRoundIcon, - CodeXmlIcon, UsersIcon, ActivityIcon, - CopyIcon, CheckIcon, + UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, } from "lucide-react" -// ─── collect all WireGuard interfaces from all servers ──────────────────────── - -function collectInterfaces(): WgIfaceWithServer[] { +function collectMockInterfaces(): WgIfaceWithServer[] { const result: WgIfaceWithServer[] = [] - for (const srv of servers) { + for (const srv of mockServers) { for (const wg of srv.wireGuardIfaces ?? []) { result.push({ ...wg, @@ -42,117 +52,342 @@ function collectInterfaces(): WgIfaceWithServer[] { return result } -// ─── helpers ────────────────────────────────────────────────────────────────── - -// ─── RSC generator ──────────────────────────────────────────────────────────── - -function generateWgRsc(iface: WgIfaceWithServer): string { - const lines: string[] = [] - lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`) - lines.push(`# RouterOS 7.x`) - lines.push(``) - lines.push(`/interface wireguard add \\`) - lines.push(` name=${iface.name} \\`) - lines.push(` listen-port=${iface.listenPort} \\`) - lines.push(` mtu=${iface.mtu} \\`) - if (iface.comment) lines.push(` comment="${iface.comment}" \\`) - if (!iface.enabled) lines.push(` disabled=yes \\`) - lines.push(``) - for (const p of iface.peers) { - lines.push(`/interface wireguard peers add \\`) - lines.push(` interface=${iface.name} \\`) - lines.push(` public-key="${p.publicKey}" \\`) - lines.push(` allowed-address=${p.allowedIps.join(",")} \\`) - if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`) - if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`) - if (p.persistent) lines.push(` persistent-keepalive=25 \\`) - if (p.comment) lines.push(` comment="${p.comment}" \\`) - lines.push(``) +function dtoToRow(d: WgIfaceDto): WgIfaceWithServer { + return { + id: d.id, + rosId: d.rosId, + name: d.name, + listenPort: d.listenPort, + mtu: d.mtu, + publicKey: d.publicKey, + privateKey: d.privateKey, + address: d.address, + peers: d.peers.map((p) => ({ + id: p.id, + rosId: p.rosId, + publicKey: p.publicKey, + allowedIps: p.allowedIps, + endpoint: p.endpoint, + latestHandshake: p.latestHandshake, + transferRx: p.transferRx, + transferTx: p.transferTx, + persistentKeepalive: p.persistentKeepalive, + persistent: p.persistent, + comment: p.comment, + disabled: p.disabled, + name: p.name, + clientAddress: p.clientAddress, + clientDns: p.clientDns, + clientEndpoint: p.clientEndpoint, + })), + comment: d.comment, + enabled: d.enabled, + status: d.status, + serverId: d.serverId, + serverName: d.serverName, + serverCountry: d.serverCountry ?? "UN", } - return lines.join("\n") } -// ─── Export Sheet ───────────────────────────────────────────────────────────── +interface BackendServer { + id: number + name: string + host: string + country: string + enabled: boolean +} -function ExportSheet({ open, iface, onClose }: { - open: boolean; iface: WgIfaceWithServer | null; onClose: () => void -}) { - const [copied, setCopied] = useState(false) - const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface]) +function mapBackendServer(s: BackendServer): Server { + return { + id: String(s.id), + name: s.name || s.host, + host: s.host, + model: "—", + os: "—", + site: "", + country: s.country || "UN", + asn: "", + type: "exit-node", + enabled: s.enabled, + status: "online", + latency: null, + sessions: 0, + } +} - function handleCopy() { - navigator.clipboard.writeText(code).then(() => { - setCopied(true); setTimeout(() => setCopied(false), 2000) - }) +function parseEndpoint(endpoint: string): { address?: string; port?: number } { + const t = endpoint.trim() + if (!t) return {} + const idx = t.lastIndexOf(":") + if (idx <= 0) return { address: t } + return { + address: t.slice(0, idx), + port: Number.parseInt(t.slice(idx + 1), 10) || undefined, } - - return ( - { if (!v) onClose() }}> - - -
-
- Экспорт WireGuard - RouterOS 7.x · /interface wireguard + peers -
- -
-
-
-
-            {code.split("\n").map((line, i) => {
-              const isComment = line.startsWith("#")
-              const isCmd = line.trimStart().startsWith("/interface")
-              const isParam = /^\s+[a-z]/.test(line)
-              return (
-                
-                  {line}{"\n"}
-                
-              )
-            })}
-          
-
- - }>Закрыть - - -
-
- ) } -// ════════════════════════════════════════════════════════════════════════════ export default function WireGuardPage() { - const allIfaces = useMemo(() => collectInterfaces(), []) + const { mode, backendUrl } = useDataSource() + const isLive = mode === "live" + + const [liveIfaces, setLiveIfaces] = useState([]) + const [liveServers, setLiveServers] = useState([]) + const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(false) const [search, setSearch] = useState("") + const [createOpen, setCreateOpen] = useState(false) + const [importOpen, setImportOpen] = useState(false) const [exportIface, setExportIface] = useState(null) + const [peerIface, setPeerIface] = useState(null) + const [liveExport, setLiveExport] = useState<{ + rsc?: string + conf?: string + peerConf?: string + } | null>(null) + const [exportBusy, setExportBusy] = useState(false) + + const loadLive = useCallback(async () => { + if (!isLive) return + setLoading(true) + try { + const [wg, servers] = await Promise.all([ + listWireGuard(backendUrl), + requestJson(backendUrl, "/api/servers"), + ]) + setLiveIfaces(wg.interfaces.map(dtoToRow)) + setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) + if (wg.failures?.length) { + toast.warning( + `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, + ) + } + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") + setLiveIfaces([]) + } finally { + setLoading(false) + } + }, [isLive, backendUrl]) + + useEffect(() => { + if (!isLive) { + queueMicrotask(() => { + setLiveIfaces([]) + setLiveServers([]) + }) + return + } + queueMicrotask(() => { + void loadLive() + }) + }, [isLive, loadLive]) + + const displayIfaces = isLive ? liveIfaces : collectMockInterfaces() + const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const filtered = useMemo(() => { - if (!search) return allIfaces + if (!search) return displayIfaces const q = search.toLowerCase() - return allIfaces.filter((i) => - i.name.includes(q) || - i.serverName.toLowerCase().includes(q) || - i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q)) + return displayIfaces.filter( + (i) => + i.name.toLowerCase().includes(q) || + i.serverName.toLowerCase().includes(q) || + i.peers.some( + (p) => + p.allowedIps.some((a) => a.includes(q)) || + (p.endpoint ?? "").includes(q), + ), ) - }, [allIfaces, search]) + }, [displayIfaces, search]) - const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0) - const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0) - const upIfaces = allIfaces.filter((i) => i.status === "up").length + const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0) + const onlinePeers = displayIfaces.reduce( + (s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, + 0, + ) + const upIfaces = displayIfaces.filter((i) => i.status === "up").length + + const serverOptions = displayServers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + })) + + async function handleCreate(form: WgCreateFormState) { + if (!isLive) { + toast.info("Создание на роутер доступно только в live-режиме") + return + } + setBusy(true) + try { + const ep = parseEndpoint(form.peerEndpoint) + await createWireGuardInterface(backendUrl, { + serverId: form.serverId, + name: form.name.trim(), + listenPort: Number.parseInt(form.listenPort, 10) || 13231, + mtu: Number.parseInt(form.mtu, 10) || 1420, + comment: form.comment || undefined, + address: form.address.trim() || undefined, + disabled: !form.enabled, + peer: form.peerEnabled && form.peerPublicKey.trim() + ? { + publicKey: form.peerPublicKey.trim(), + allowedAddresses: form.peerAllowedIps + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + endpointAddress: ep.address, + endpointPort: ep.port, + persistentKeepalive: Number.parseInt(form.peerKeepalive, 10) || undefined, + comment: form.peerComment || undefined, + } + : undefined, + }) + toast.success(`Интерфейс ${form.name} создан`) + setCreateOpen(false) + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка создания") + } finally { + setBusy(false) + } + } + + async function handleImport(args: { + serverId: string + content: string + format: "auto" | "rsc" | "conf" + dryRun: boolean + }) { + if (!isLive) { + toast.info("Импорт на роутер доступен только в live-режиме") + return + } + setBusy(true) + try { + const res = await importWireGuard(backendUrl, { + serverId: args.serverId, + content: args.content, + format: args.format, + dryRun: args.dryRun, + }) + toast.success( + res.applied + ? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)` + : "Импорт выполнен", + ) + setImportOpen(false) + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка импорта") + } finally { + setBusy(false) + } + } + + async function handleToggle(iface: WgIfaceWithServer) { + if (!isLive || !iface.rosId) { + toast.info("Доступно только в live-режиме") + return + } + try { + await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, { + disabled: iface.enabled, + }) + toast.success(iface.enabled ? "Отключено" : "Включено") + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка") + } + } + + async function handleDelete(iface: WgIfaceWithServer) { + if (!isLive || !iface.rosId) { + toast.info("Доступно только в live-режиме") + return + } + if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return + try { + await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId) + toast.success("Удалено") + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка удаления") + } + } + + async function handleAddPeer(form: WgPeerFormState) { + if (!isLive || !peerIface) { + toast.info("Доступно только в live-режиме") + return + } + setBusy(true) + try { + const ep = parseEndpoint(form.endpoint) + await createWireGuardPeer(backendUrl, { + serverId: peerIface.serverId, + interfaceName: peerIface.name, + publicKey: form.publicKey.trim(), + allowedAddresses: form.allowedIps + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + endpointAddress: ep.address, + endpointPort: ep.port, + persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined, + comment: form.comment || undefined, + }) + toast.success("Пир добавлен") + setPeerIface(null) + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка") + } finally { + setBusy(false) + } + } + + async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) { + if (!isLive) { + toast.info("Доступно только в live-режиме") + return + } + if (!window.confirm("Удалить пира?")) return + try { + await deleteWireGuardPeer(backendUrl, iface.serverId, peerId) + toast.success("Пир удалён") + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка") + } + } + + async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") { + if (!exportIface || !isLive) return + setExportBusy(true) + try { + const res = await exportWireGuard(backendUrl, { + serverId: exportIface.serverId, + interfaceName: exportIface.name, + format, + includePrivateKey: format !== "peer-conf", + }) + setLiveExport((prev) => ({ + ...prev, + ...(format === "rsc" + ? { rsc: res.content } + : format === "conf" + ? { conf: res.content } + : { peerConf: res.content }), + })) + toast.success("Конфиг загружен с роутера") + } catch (e) { + toast.error(e instanceof Error ? e.message : "Ошибка экспорта") + } finally { + setExportBusy(false) + } + } return (
@@ -160,8 +395,24 @@ export default function WireGuardPage() { crumbs={[{ label: "Управление" }, { label: "WireGuard" }]} actions={ <> - + )} + + } @@ -169,14 +420,12 @@ export default function WireGuardPage() {
- - {/* KPI */}
{[ - { label: "Интерфейсов", value: allIfaces.length, icon: }, - { label: "Активных (UP)", value: upIfaces, icon: }, - { label: "Всего пиров", value: totalPeers, icon: }, - { label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: }, + { label: "Интерфейсов", value: displayIfaces.length, icon: }, + { label: "Активных (UP)", value: upIfaces, icon: }, + { label: "Всего пиров", value: totalPeers, icon: }, + { label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: }, ].map((s) => ( @@ -192,19 +441,20 @@ export default function WireGuardPage() { ))}
- {/* Info banner */}
-

WireGuard — рекомендуемый туннельный протокол в RouterOS 7.x

+

+ WireGuard — live-интеграция RouterOS 7.x +

- Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec. - Ключи генерируются командой /interface/wireguard/print. + {isLive + ? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера." + : "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}

- {/* Search + table */} { + setLiveExport(null) + setExportIface(iface) + }} + onAddPeer={setPeerIface} + onToggle={handleToggle} + onDelete={handleDelete} + onDeletePeer={handleDeletePeer} + onExportPeer={(iface) => { + setLiveExport(null) + setExportIface(iface) + }} /> - {/* RouterOS reference */} -
- {[ - { - title: "Создать интерфейс", - lines: [ - "/interface wireguard add \\", - " name=wg0 \\", - " listen-port=13231 \\", - " mtu=1420", - ], - }, - { - title: "Добавить пира", - lines: [ - "/interface wireguard peers add \\", - " interface=wg0 \\", - ' public-key="<ключ>" \\', - " allowed-address=10.0.0.2/32 \\", - " endpoint-address=1.2.3.4 \\", - " persistent-keepalive=25", - ], - }, - { - title: "Назначить IP", - lines: [ - "/ip address add \\", - " address=10.210.0.1/30 \\", - " interface=wg0", - "", - "# Статус:", - "/interface wireguard print", - ], - }, - ].map((b) => ( -
-

{b.title}

-
-                      {b.lines.join("\n")}
-                    
-
- ))} -
+
+ {[ + { + title: "Создать интерфейс", + lines: [ + "/interface wireguard add \\", + " name=wg0 \\", + " listen-port=13231 \\", + " mtu=1420", + ], + }, + { + title: "Добавить пира", + lines: [ + "/interface wireguard peers add \\", + " interface=wg0 \\", + ' public-key="<ключ>" \\', + " allowed-address=10.0.0.2/32 \\", + " endpoint-address=1.2.3.4 \\", + " persistent-keepalive=25", + ], + }, + { + title: "Назначить IP", + lines: [ + "/ip address add \\", + " address=10.210.0.1/30 \\", + " interface=wg0", + "", + "# Статус:", + "/interface wireguard print", + ], + }, + ].map((b) => ( +
+

+ {b.title} +

+
+                    {b.lines.join("\n")}
+                  
+
+ ))} +
-
- + + { if (!v) setPeerIface(null) }} + onSubmit={handleAddPeer} + /> + setExportIface(null)} + onClose={() => { + setExportIface(null) + setLiveExport(null) + }} + liveContent={liveExport} + liveBusy={exportBusy} + onRequestLiveExport={isLive ? handleLiveExport : undefined} />
) diff --git a/backend/package.json b/backend/package.json index 768566a..6572c0a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", - "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts" + "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", + "test:wireguard": "npx tsx src/services/wireguard-config.test.ts" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index f93305f..35684da 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,6 +23,7 @@ import backupsRoutes from "./routes/backups.js" import certificatesRoutes from "./routes/certificates.js" import systemDatabaseRoutes from "./routes/system-database.js" import eventsRoutes from "./routes/events.js" +import wireguardRoutes from "./routes/wireguard.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js" export async function buildApp(opts?: { @@ -103,6 +104,7 @@ export async function buildApp(opts?: { await app.register(certificatesRoutes, { prefix: "/api" }) await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" }) + await app.register(wireguardRoutes, { prefix: "/api" }) if (opts?.startScheduler !== false) { refreshScheduler() diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts index c1bfb4a..9715ffd 100644 --- a/backend/src/lib/permissions.test.ts +++ b/backend/src/lib/permissions.test.ts @@ -21,5 +21,13 @@ assert.equal( permissionForRequest("GET", "/api/unknown-thing"), "mm:dashboard:read", ) +assert.equal( + permissionForRequest("GET", "/api/wireguard"), + "mm:network:read", +) +assert.equal( + permissionForRequest("POST", "/api/wireguard/interfaces"), + "mm:network:write", +) console.log("permissions.test.ts: ok") diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index 9d6bb6a..bb53778 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -141,7 +141,8 @@ const RULES: Rule[] = [ p.startsWith("/api/recursive") || p.startsWith("/api/probes") || p.startsWith("/api/internet-path") || - p.startsWith("/api/exec"), + p.startsWith("/api/exec") || + p.startsWith("/api/wireguard"), permission: "mm:network:read", }, { @@ -152,7 +153,8 @@ const RULES: Rule[] = [ p.startsWith("/api/recursive") || p.startsWith("/api/probes") || p.startsWith("/api/internet-path") || - p.startsWith("/api/exec"), + p.startsWith("/api/exec") || + p.startsWith("/api/wireguard"), permission: "mm:network:write", }, ] diff --git a/backend/src/routes/sidebar-counts.ts b/backend/src/routes/sidebar-counts.ts index 7eca8e0..0d34aeb 100644 --- a/backend/src/routes/sidebar-counts.ts +++ b/backend/src/routes/sidebar-counts.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { listCertificatesFromServers } from "../services/certificates-service.js" +import { countWireGuardInterfaces } from "../services/wireguard-live.js" import { db } from "../db/index.js" import { filterRules, @@ -18,6 +19,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { uptimeSpeedProbesTotal, recursiveRoutesTotal, certificatesTotal, + wireguardTotal, ] = await Promise.all([ Promise.resolve(db.select().from(servers).all().length), Promise.resolve(db.select().from(filterRules).all().length), @@ -25,6 +27,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { Promise.resolve(db.select().from(uptimeSpeedProbes).all().length), Promise.resolve(db.select().from(recursiveRoutes).all().length), listCertificatesFromServers().then((res) => res.certificates.length), + countWireGuardInterfaces().catch(() => 0), ]) return reply.send({ @@ -35,6 +38,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal, recursiveRoutes: recursiveRoutesTotal, certificates: certificatesTotal, + wireguard: wireguardTotal, }) }) } diff --git a/backend/src/routes/wireguard.ts b/backend/src/routes/wireguard.ts new file mode 100644 index 0000000..4ca0fcb --- /dev/null +++ b/backend/src/routes/wireguard.ts @@ -0,0 +1,456 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { + wgCreateInterfaceSchema, + wgCreatePeerRequestSchema, + wgExportRequestSchema, + wgImportRequestSchema, + wgPatchInterfaceSchema, + wgPatchPeerSchema, + type WgCreatePeerRequest, + type WgIfaceDto, +} from "@mmapp/contracts/wireguard" +import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" +import { + generateMikrotikRsc, + generateNativeConf, + generatePeerClientConf, + parseWgConfig, + type WgParsedConfig, +} from "../services/wireguard-config.js" +import { + getEnabledServerById, + listWireGuardInterfaces, +} from "../services/wireguard-live.js" + +function serverIdParam(v: string): string { + return decodeURIComponent(v) +} + +function rosIdParam(v: string): string { + return decodeURIComponent(v) +} + +function toRosBody(obj: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined && v !== "") out[k] = v + } + return out +} + +function peerToRosBody(p: Omit & { interfaceName: string }) { + return toRosBody({ + interface: p.interfaceName, + "public-key": p.publicKey, + "allowed-address": p.allowedAddresses.join(","), + "endpoint-address": p.endpointAddress, + "endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined, + "persistent-keepalive": + p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined, + comment: p.comment, + name: p.name, + "private-key": typeof p.privateKey === "string" ? p.privateKey : undefined, + "client-address": p.clientAddress, + "client-dns": p.clientDns, + "client-endpoint": p.clientEndpoint, + disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined, + }) +} + +function previewFromParsed(parsed: WgParsedConfig) { + return { + format: parsed.format, + interface: { + name: parsed.interface.name, + listenPort: parsed.interface.listenPort, + mtu: parsed.interface.mtu, + privateKey: parsed.interface.privateKey, + comment: parsed.interface.comment, + address: parsed.interface.address, + disabled: parsed.interface.disabled, + }, + peers: parsed.peers.map((p) => ({ + publicKey: p.publicKey, + allowedAddresses: p.allowedAddresses, + endpointAddress: p.endpointAddress, + endpointPort: p.endpointPort, + persistentKeepalive: p.persistentKeepalive, + comment: p.comment, + name: p.name, + privateKey: p.privateKey, + clientAddress: p.clientAddress, + clientDns: p.clientDns, + clientEndpoint: p.clientEndpoint, + disabled: p.disabled, + })), + } +} + +async function applyParsedConfig( + client: MikrotikClient, + parsed: WgParsedConfig, +): Promise<{ interfaceName: string; peersCreated: number }> { + const name = parsed.interface.name + const ifaceBody = toRosBody({ + name, + "listen-port": String(parsed.interface.listenPort ?? 13231), + mtu: String(parsed.interface.mtu ?? 1420), + "private-key": parsed.interface.privateKey, + comment: parsed.interface.comment, + disabled: parsed.interface.disabled ? "yes" : undefined, + }) + await client.put("/interface/wireguard", ifaceBody) + + if (parsed.interface.address) { + await client.put("/ip/address", { + address: parsed.interface.address, + interface: name, + }) + } + + let peersCreated = 0 + for (const p of parsed.peers) { + if (!p.publicKey) continue + await client.put( + "/interface/wireguard/peers", + peerToRosBody({ + interfaceName: name, + publicKey: p.publicKey, + allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"], + endpointAddress: p.endpointAddress, + endpointPort: p.endpointPort, + persistentKeepalive: p.persistentKeepalive, + comment: p.comment, + name: p.name, + privateKey: p.privateKey, + clientAddress: p.clientAddress, + clientDns: p.clientDns, + clientEndpoint: p.clientEndpoint, + disabled: p.disabled, + }), + ) + peersCreated += 1 + } + return { interfaceName: name, peersCreated } +} + +function findIface( + list: WgIfaceDto[], + serverId: string, + interfaceName: string, +): WgIfaceDto | undefined { + return list.find((i) => i.serverId === serverId && i.name === interfaceName) +} + +const wireguardRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/wireguard", async (req, reply) => { + const q = req.query as { serverId?: string; includePrivateKey?: string } + const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true" + const result = await listWireGuardInterfaces({ + serverId: q.serverId, + includePrivateKey, + }) + return reply.send(result) + }) + + app.post("/wireguard/interfaces", async (req, reply) => { + const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = getEnabledServerById(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + + const client = MikrotikClient.fromServer(server) + try { + await client.put( + "/interface/wireguard", + toRosBody({ + name: body.name, + "listen-port": String(body.listenPort), + mtu: String(body.mtu), + comment: body.comment, + "private-key": body.privateKey, + disabled: body.disabled ? "yes" : undefined, + }), + ) + + if (body.address) { + await client.put("/ip/address", { + address: body.address, + interface: body.name, + }) + } + + if (body.peer) { + await client.put( + "/interface/wireguard/peers", + peerToRosBody({ ...body.peer, interfaceName: body.name }), + ) + } + + const list = await listWireGuardInterfaces({ + serverId: String(server.id), + includePrivateKey: true, + }) + const created = list.interfaces.find((i) => i.name === body.name) + 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) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) + } + }) + + app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const server = getEnabledServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const d = parsed.data + try { + await client.patch( + `/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`, + toRosBody({ + name: d.name, + "listen-port": d.listenPort != null ? String(d.listenPort) : undefined, + mtu: d.mtu != null ? String(d.mtu) : undefined, + comment: d.comment, + "private-key": d.privateKey, + disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, + }), + ) + return reply.send({ ok: true }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) + } + }) + + app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const server = getEnabledServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`) + return reply.send({ ok: true }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) + } + }) + + app.post("/wireguard/peers", async (req, reply) => { + const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = getEnabledServerById(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + await client.put("/interface/wireguard/peers", peerToRosBody(body)) + return reply.status(201).send({ ok: true }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) + } + }) + + app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const parsed = wgPatchPeerSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const server = getEnabledServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const d = parsed.data + const client = MikrotikClient.fromServer(server) + try { + await client.patch( + `/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`, + toRosBody({ + "public-key": d.publicKey, + "allowed-address": d.allowedAddresses?.join(","), + "endpoint-address": d.endpointAddress, + "endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined, + "persistent-keepalive": + d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined, + comment: d.comment, + name: d.name, + "client-address": d.clientAddress, + "client-dns": d.clientDns, + "client-endpoint": d.clientEndpoint, + disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, + }), + ) + return reply.send({ ok: true }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) + } + }) + + app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => { + const { serverId, rosId } = req.params as { serverId: string; rosId: string } + const server = getEnabledServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`) + return reply.send({ ok: true }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}` }) + } + }) + + app.post("/wireguard/import", async (req, reply) => { + const parsed = wgImportRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + let config: WgParsedConfig + try { + config = parseWgConfig(body.content, body.format) + } catch (e) { + return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" }) + } + const preview = previewFromParsed(config) + if (body.dryRun) { + return reply.send({ dryRun: true, preview }) + } + + const server = getEnabledServerById(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const applied = await applyParsedConfig(client, config) + return reply.send({ dryRun: false, preview, applied }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.status(502).send({ error: `RouterOS: ${msg}`, preview }) + } + }) + + app.post("/wireguard/export", async (req, reply) => { + const parsed = wgExportRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = getEnabledServerById(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + + const list = await listWireGuardInterfaces({ + serverId: String(server.id), + includePrivateKey: body.includePrivateKey === true, + }) + const iface = findIface(list.interfaces, String(server.id), body.interfaceName) + if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" }) + + if (body.format === "rsc") { + const content = generateMikrotikRsc({ + name: iface.name, + listenPort: iface.listenPort, + mtu: iface.mtu, + comment: iface.comment, + enabled: iface.enabled, + privateKey: body.includePrivateKey ? iface.privateKey : undefined, + publicKey: iface.publicKey, + address: iface.address, + serverName: iface.serverName, + peers: iface.peers.map((p) => ({ + publicKey: p.publicKey, + allowedIps: p.allowedIps, + endpoint: p.endpoint, + persistentKeepalive: p.persistentKeepalive, + persistent: p.persistent, + comment: p.comment, + name: p.name, + clientAddress: p.clientAddress, + clientDns: p.clientDns, + clientEndpoint: p.clientEndpoint, + })), + }) + return reply.send({ + format: "rsc", + filename: `${iface.name}.rsc`, + content, + }) + } + + if (body.format === "conf") { + const content = generateNativeConf( + { + name: iface.name, + listenPort: iface.listenPort, + mtu: iface.mtu, + comment: iface.comment, + enabled: iface.enabled, + privateKey: iface.privateKey, + publicKey: iface.publicKey, + address: iface.address, + serverName: iface.serverName, + peers: iface.peers.map((p) => ({ + publicKey: p.publicKey, + allowedIps: p.allowedIps, + endpoint: p.endpoint, + persistentKeepalive: p.persistentKeepalive, + persistent: p.persistent, + comment: p.comment, + })), + }, + { includePrivateKey: body.includePrivateKey === true }, + ) + return reply.send({ + format: "conf", + filename: `${iface.name}.conf`, + content, + }) + } + + // peer-conf + const peer = body.peerId + ? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId) + : iface.peers[0] + if (!peer) return reply.status(404).send({ error: "Пир не найден" }) + if (!iface.publicKey) { + return reply.status(400).send({ error: "У интерфейса нет public-key" }) + } + const endpoint = + peer.clientEndpoint || + (peer.endpoint + ? peer.endpoint + : undefined) + const content = generatePeerClientConf({ + peerAddress: peer.clientAddress, + peerDns: peer.clientDns, + serverPublicKey: iface.publicKey, + allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"], + endpoint: + endpoint || + (peer.clientEndpoint + ? peer.clientEndpoint.includes(":") + ? peer.clientEndpoint + : `${peer.clientEndpoint}:${iface.listenPort}` + : undefined), + persistentKeepalive: peer.persistentKeepalive ?? 25, + }) + return reply.send({ + format: "peer-conf", + filename: `${iface.name}-peer.conf`, + content, + }) + }) +} + +export default wireguardRoutes diff --git a/backend/src/services/wireguard-config.test.ts b/backend/src/services/wireguard-config.test.ts new file mode 100644 index 0000000..4370ad1 --- /dev/null +++ b/backend/src/services/wireguard-config.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict" +import { + detectWgConfigFormat, + generateMikrotikRsc, + generateNativeConf, + parseMikrotikRsc, + parseNativeConf, + parseWgConfig, +} from "./wireguard-config.js" + +const sampleConf = `[Interface] +PrivateKey = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa= +Address = 10.210.0.1/30 +ListenPort = 13231 +MTU = 1420 + +[Peer] +PublicKey = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb= +AllowedIPs = 10.210.0.2/32, 192.168.20.0/24 +Endpoint = 10.0.1.1:13231 +PersistentKeepalive = 25 +` + +const parsedConf = parseNativeConf(sampleConf) +assert.equal(parsedConf.format, "conf") +assert.equal(parsedConf.interface.listenPort, 13231) +assert.equal(parsedConf.interface.address, "10.210.0.1/30") +assert.equal(parsedConf.peers.length, 1) +assert.equal(parsedConf.peers[0]?.endpointAddress, "10.0.1.1") +assert.equal(parsedConf.peers[0]?.endpointPort, 13231) +assert.deepEqual(parsedConf.peers[0]?.allowedAddresses, ["10.210.0.2/32", "192.168.20.0/24"]) + +const roundConf = generateNativeConf({ + name: "wg0", + listenPort: parsedConf.interface.listenPort ?? 13231, + mtu: parsedConf.interface.mtu ?? 1420, + privateKey: parsedConf.interface.privateKey, + address: parsedConf.interface.address, + peers: parsedConf.peers.map((p) => ({ + publicKey: p.publicKey, + allowedIps: p.allowedAddresses, + endpoint: p.endpointAddress + ? `${p.endpointAddress}:${p.endpointPort ?? 13231}` + : undefined, + persistentKeepalive: p.persistentKeepalive, + })), +}) +const reparsed = parseNativeConf(roundConf) +assert.equal(reparsed.interface.privateKey, parsedConf.interface.privateKey) +assert.equal(reparsed.peers[0]?.publicKey, parsedConf.peers[0]?.publicKey) + +const sampleRsc = `# WireGuard +/interface wireguard add \\ + name=wg-msk-spb \\ + listen-port=13231 \\ + mtu=1420 \\ + comment="MSK → SPB" + +/ip address add \\ + address=10.210.0.1/30 \\ + interface=wg-msk-spb + +/interface wireguard peers add \\ + interface=wg-msk-spb \\ + public-key="SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=" \\ + allowed-address=10.210.0.2/32,192.168.20.0/24 \\ + endpoint-address=10.0.1.1 \\ + endpoint-port=13231 \\ + persistent-keepalive=25 +` + +assert.equal(detectWgConfigFormat(sampleRsc), "rsc") +assert.equal(detectWgConfigFormat(sampleConf), "conf") + +const parsedRsc = parseMikrotikRsc(sampleRsc) +assert.equal(parsedRsc.interface.name, "wg-msk-spb") +assert.equal(parsedRsc.interface.address, "10.210.0.1/30") +assert.equal(parsedRsc.peers.length, 1) +assert.equal(parsedRsc.peers[0]?.endpointPort, 13231) + +const generatedRsc = generateMikrotikRsc({ + name: parsedRsc.interface.name, + listenPort: parsedRsc.interface.listenPort ?? 13231, + mtu: parsedRsc.interface.mtu ?? 1420, + comment: parsedRsc.interface.comment, + address: parsedRsc.interface.address, + peers: parsedRsc.peers.map((p) => ({ + publicKey: p.publicKey, + allowedIps: p.allowedAddresses, + endpoint: p.endpointAddress + ? `${p.endpointAddress}:${p.endpointPort ?? 13231}` + : undefined, + persistentKeepalive: p.persistentKeepalive, + })), +}) +const rscAgain = parseWgConfig(generatedRsc, "rsc") +assert.equal(rscAgain.interface.name, "wg-msk-spb") +assert.equal(rscAgain.peers[0]?.publicKey, parsedRsc.peers[0]?.publicKey) + +console.log("wireguard-config tests ok") diff --git a/backend/src/services/wireguard-config.ts b/backend/src/services/wireguard-config.ts new file mode 100644 index 0000000..e2b1f9b --- /dev/null +++ b/backend/src/services/wireguard-config.ts @@ -0,0 +1,359 @@ +/** + * WireGuard config codecs: native .conf ↔ MikroTik .rsc + */ + +export type WgParsedPeer = { + publicKey: string + allowedAddresses: string[] + endpointAddress?: string + endpointPort?: number + persistentKeepalive?: number + comment?: string + name?: string + privateKey?: "auto" | "none" | string + clientAddress?: string + clientDns?: string + clientEndpoint?: string + disabled?: boolean +} + +export type WgParsedInterface = { + name: string + listenPort?: number + mtu?: number + privateKey?: string + comment?: string + address?: string + disabled?: boolean +} + +export type WgParsedConfig = { + format: "rsc" | "conf" + interface: WgParsedInterface + peers: WgParsedPeer[] +} + +export type WgExportIface = { + name: string + listenPort: number + mtu: number + comment?: string + enabled?: boolean + privateKey?: string + publicKey?: string + address?: string + serverName?: string + peers: Array<{ + publicKey: string + allowedIps: string[] + endpoint?: string + persistentKeepalive?: number + persistent?: boolean + comment?: string + name?: string + clientAddress?: string + clientDns?: string + clientEndpoint?: string + }> +} + +function stripQuotes(v: string): string { + const t = v.trim() + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1) + } + return t +} + +function parseKvLine(line: string): Record { + const out: Record = {} + // Match key=value pairs; values may be quoted + const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g + let m: RegExpExecArray | null + while ((m = re.exec(line)) !== null) { + out[m[1]] = stripQuotes(m[2]) + } + return out +} + +function joinContinuedLines(text: string): string[] { + const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n") + const lines: string[] = [] + let buf = "" + for (const line of raw) { + const trimmedEnd = line.replace(/\s+$/, "") + if (trimmedEnd.endsWith("\\")) { + buf += trimmedEnd.slice(0, -1).trimEnd() + " " + continue + } + buf += trimmedEnd + if (buf.trim()) lines.push(buf.trim()) + buf = "" + } + if (buf.trim()) lines.push(buf.trim()) + return lines +} + +export function detectWgConfigFormat(content: string): "rsc" | "conf" { + const t = content.trim() + if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf" + if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc" + if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf" + return "rsc" +} + +export function parseNativeConf(content: string): WgParsedConfig { + const lines = content.replace(/\r\n/g, "\n").split("\n") + let section: "interface" | "peer" | null = null + const iface: WgParsedInterface = { name: "wg0" } + const peers: WgParsedPeer[] = [] + let currentPeer: WgParsedPeer | null = null + + const flushPeer = () => { + if (currentPeer?.publicKey) peers.push(currentPeer) + currentPeer = null + } + + for (const raw of lines) { + const line = raw.trim() + if (!line || line.startsWith("#") || line.startsWith(";")) continue + if (/^\[Interface\]$/i.test(line)) { + flushPeer() + section = "interface" + continue + } + if (/^\[Peer\]$/i.test(line)) { + flushPeer() + section = "peer" + currentPeer = { publicKey: "", allowedAddresses: [] } + continue + } + const eq = line.indexOf("=") + if (eq < 0) continue + const key = line.slice(0, eq).trim().toLowerCase() + const value = line.slice(eq + 1).trim() + + if (section === "interface") { + if (key === "privatekey") iface.privateKey = value + else if (key === "address") iface.address = value.split(",")[0]?.trim() + else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined + else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined + else if (key === "name") iface.name = value || iface.name + } else if (section === "peer" && currentPeer) { + if (key === "publickey") currentPeer.publicKey = value + else if (key === "allowedips") { + currentPeer.allowedAddresses = value + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + } else if (key === "endpoint") { + const lastColon = value.lastIndexOf(":") + if (lastColon > 0 && !value.includes("]:")) { + currentPeer.endpointAddress = value.slice(0, lastColon) + currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined + } else if (value.startsWith("[") && value.includes("]:")) { + const idx = value.indexOf("]:") + currentPeer.endpointAddress = value.slice(1, idx) + currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined + } else { + currentPeer.endpointAddress = value + } + } else if (key === "persistentkeepalive") { + currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined + } else if (key === "presharedkey") { + // ignore PSK for ROS import for now + } + } + } + flushPeer() + + if (!iface.name) iface.name = "wg0" + return { format: "conf", interface: iface, peers } +} + +export function parseMikrotikRsc(content: string): WgParsedConfig { + const lines = joinContinuedLines(content) + const iface: WgParsedInterface = { name: "wg0" } + const peers: WgParsedPeer[] = [] + let foundIface = false + + for (const line of lines) { + if (line.startsWith("#")) continue + const lower = line.toLowerCase() + + if ( + lower.startsWith("/interface wireguard add") || + lower.startsWith("/interface/wireguard add") + ) { + const kv = parseKvLine(line) + if (kv.name) iface.name = kv.name + if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined + if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined + if (kv["private-key"]) iface.privateKey = kv["private-key"] + if (kv.comment) iface.comment = kv.comment + if (kv.disabled === "yes") iface.disabled = true + foundIface = true + continue + } + + if ( + lower.startsWith("/interface wireguard peers add") || + lower.startsWith("/interface/wireguard/peers add") + ) { + const kv = parseKvLine(line) + const allowed = (kv["allowed-address"] ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + peers.push({ + publicKey: kv["public-key"] ?? "", + allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"], + endpointAddress: kv["endpoint-address"], + endpointPort: kv["endpoint-port"] + ? Number.parseInt(kv["endpoint-port"], 10) || undefined + : undefined, + persistentKeepalive: kv["persistent-keepalive"] + ? Number.parseInt(kv["persistent-keepalive"], 10) || undefined + : undefined, + comment: kv.comment, + name: kv.name, + clientAddress: kv["client-address"], + clientDns: kv["client-dns"], + clientEndpoint: kv["client-endpoint"], + disabled: kv.disabled === "yes", + }) + continue + } + + if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) { + const kv = parseKvLine(line) + if (kv.address) iface.address = kv.address + continue + } + } + + if (!foundIface && peers.length === 0) { + throw new Error("Не удалось распознать RouterOS WireGuard .rsc") + } + return { format: "rsc", interface: iface, peers } +} + +export function parseWgConfig( + content: string, + format: "auto" | "rsc" | "conf" = "auto", +): WgParsedConfig { + const detected = format === "auto" ? detectWgConfigFormat(content) : format + if (detected === "conf") return parseNativeConf(content) + return parseMikrotikRsc(content) +} + +export function generateNativeConf(iface: WgExportIface, opts?: { includePrivateKey?: boolean }): string { + const lines: string[] = [] + lines.push(`[Interface]`) + if (opts?.includePrivateKey && iface.privateKey) { + lines.push(`PrivateKey = ${iface.privateKey}`) + } else if (iface.privateKey) { + lines.push(`PrivateKey = ${iface.privateKey}`) + } else { + lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`) + } + if (iface.address) lines.push(`Address = ${iface.address}`) + lines.push(`ListenPort = ${iface.listenPort}`) + if (iface.mtu) lines.push(`MTU = ${iface.mtu}`) + lines.push(``) + + for (const p of iface.peers) { + lines.push(`[Peer]`) + lines.push(`PublicKey = ${p.publicKey}`) + lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`) + if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`) + const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined) + if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`) + if (p.comment) lines.push(`# ${p.comment}`) + lines.push(``) + } + return lines.join("\n").trimEnd() + "\n" +} + +export function generatePeerClientConf(args: { + peerPrivateKey?: string + peerAddress?: string + peerDns?: string + serverPublicKey: string + allowedIps?: string[] + endpoint?: string + persistentKeepalive?: number +}): string { + const lines: string[] = [] + lines.push(`[Interface]`) + lines.push( + args.peerPrivateKey + ? `PrivateKey = ${args.peerPrivateKey}` + : `# PrivateKey = <ключ клиента>`, + ) + if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`) + if (args.peerDns) lines.push(`DNS = ${args.peerDns}`) + lines.push(``) + lines.push(`[Peer]`) + lines.push(`PublicKey = ${args.serverPublicKey}`) + lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`) + if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`) + if (args.persistentKeepalive != null && args.persistentKeepalive > 0) { + lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`) + } + lines.push(``) + return lines.join("\n") +} + +export function generateMikrotikRsc(iface: WgExportIface): string { + const lines: string[] = [] + lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`) + lines.push(`# RouterOS 7.x · MikrotikManager`) + lines.push(``) + lines.push(`/interface wireguard add \\`) + lines.push(` name=${iface.name} \\`) + lines.push(` listen-port=${iface.listenPort} \\`) + lines.push(` mtu=${iface.mtu} \\`) + if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`) + if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`) + if (iface.enabled === false) lines.push(` disabled=yes \\`) + // remove trailing backslash on last iface param by rewriting last line + if (lines[lines.length - 1]?.endsWith(" \\")) { + lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2) + } + lines.push(``) + + if (iface.address) { + lines.push(`/ip address add \\`) + lines.push(` address=${iface.address} \\`) + lines.push(` interface=${iface.name}`) + lines.push(``) + } + + for (const p of iface.peers) { + lines.push(`/interface wireguard peers add \\`) + lines.push(` interface=${iface.name} \\`) + lines.push(` public-key="${p.publicKey}" \\`) + lines.push(` allowed-address=${p.allowedIps.join(",")} \\`) + if (p.endpoint) { + const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint + const port = p.endpoint.includes(":") + ? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1) + : "13231" + lines.push(` endpoint-address=${host} \\`) + lines.push(` endpoint-port=${port} \\`) + } + const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined) + if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`) + if (p.name) lines.push(` name=${p.name} \\`) + if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`) + if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`) + if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`) + if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`) + if (lines[lines.length - 1]?.endsWith(" \\")) { + lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2) + } + lines.push(``) + } + return lines.join("\n") +} diff --git a/backend/src/services/wireguard-live.ts b/backend/src/services/wireguard-live.ts new file mode 100644 index 0000000..18636da --- /dev/null +++ b/backend/src/services/wireguard-live.ts @@ -0,0 +1,214 @@ +import { eq } from "drizzle-orm" +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" + +type ServerRow = typeof servers.$inferSelect + +interface RosWireGuard { + ".id"?: string + name?: string + "listen-port"?: string + mtu?: string + "public-key"?: string + "private-key"?: string + running?: string + disabled?: string + comment?: string +} + +interface RosWireGuardPeer { + ".id"?: string + interface?: string + name?: string + "public-key"?: string + "endpoint-address"?: string + "endpoint-port"?: string + "allowed-address"?: string + "last-handshake"?: string + rx?: string + tx?: string + disabled?: string + comment?: string + "persistent-keepalive"?: string + "client-address"?: string + "client-dns"?: string + "client-endpoint"?: string +} + +interface RosIpAddress { + ".id"?: string + address?: string + interface?: string + disabled?: string +} + +function parseBytes(v: string | undefined): number | undefined { + if (v == null || v === "") return undefined + const n = Number.parseInt(v, 10) + return Number.isFinite(n) ? n : undefined +} + +function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto { + const rosId = String(p[".id"] ?? `peer-${idx}`) + const allowed = (p["allowed-address"] ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + const epAddr = (p["endpoint-address"] ?? "").trim() + const epPort = (p["endpoint-port"] ?? "").trim() + const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined + const ka = p["persistent-keepalive"] + ? Number.parseInt(p["persistent-keepalive"], 10) + : undefined + return { + id: rosId, + rosId, + publicKey: p["public-key"] ?? "", + allowedIps: allowed, + endpoint, + latestHandshake: p["last-handshake"]?.trim() || undefined, + transferRx: parseBytes(p.rx), + transferTx: parseBytes(p.tx), + persistentKeepalive: Number.isFinite(ka) ? ka : undefined, + persistent: Number.isFinite(ka) && (ka as number) > 0, + comment: p.comment ?? undefined, + disabled: p.disabled === "true" || p.disabled === "yes", + name: p.name, + clientAddress: p["client-address"], + clientDns: p["client-dns"], + clientEndpoint: p["client-endpoint"], + } +} + +function mapIface( + server: ServerRow, + w: RosWireGuard, + peers: WgPeerDto[], + address: string | undefined, + includePrivateKey: boolean, +): WgIfaceDto { + const rosId = String(w[".id"] ?? w.name ?? "wg") + const name = (w.name ?? "").trim() || rosId + const disabled = w.disabled === "true" || w.disabled === "yes" + const running = w.running === "true" || w.running === "yes" + return { + id: `${server.id}:${rosId}`, + rosId, + name, + serverId: String(server.id), + serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), + serverCountry: server.country ?? undefined, + listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231, + mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420, + publicKey: w["public-key"] || undefined, + privateKey: includePrivateKey ? w["private-key"] || undefined : undefined, + address, + peers, + comment: w.comment ?? "", + enabled: !disabled, + status: disabled ? "down" : running ? "up" : "down", + } +} + +async function fetchForServer( + server: ServerRow, + includePrivateKey: boolean, +): Promise { + const client = MikrotikClient.fromServer(server) + const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([ + client.get("/interface/wireguard"), + client.get("/interface/wireguard/peers"), + client.get("/ip/address").catch(() => [] as RosIpAddress[]), + ]) + + const peersByIface = new Map() + peersRaw.forEach((p, idx) => { + const ifaceName = (p.interface ?? "").trim() + if (!ifaceName) return + const list = peersByIface.get(ifaceName) ?? [] + list.push(mapPeer(p, idx)) + peersByIface.set(ifaceName, list) + }) + + const addrByIface = new Map() + for (const a of addrsRaw) { + if (a.disabled === "true" || a.disabled === "yes") continue + const iface = (a.interface ?? "").trim() + const addr = (a.address ?? "").trim() + if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr) + } + + return ifacesRaw.map((w) => { + const name = (w.name ?? "").trim() + return mapIface( + server, + w, + peersByIface.get(name) ?? [], + addrByIface.get(name), + includePrivateKey, + ) + }) +} + +export type WgListResult = { + interfaces: WgIfaceDto[] + failures: Array<{ serverId: string; serverName?: string; error: string }> +} + +export async function listWireGuardInterfaces(opts?: { + serverId?: string + includePrivateKey?: boolean +}): Promise { + const includePrivateKey = opts?.includePrivateKey === true + let serverRows: ServerRow[] + if (opts?.serverId) { + const id = Number.parseInt(String(opts.serverId), 10) + if (!Number.isFinite(id)) { + return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] } + } + const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] + serverRows = row ? [row] : [] + } else { + serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all() + } + + const failures: WgListResult["failures"] = [] + const results = await Promise.all( + serverRows.map(async (server) => { + try { + return await fetchForServer(server, includePrivateKey) + } catch (e) { + failures.push({ + serverId: String(server.id), + serverName: server.name ?? undefined, + error: e instanceof Error ? e.message : String(e), + }) + return [] as WgIfaceDto[] + } + }), + ) + return { interfaces: results.flat(), failures } +} + +export async function countWireGuardInterfaces(): Promise { + try { + const result = await Promise.race([ + listWireGuardInterfaces({ includePrivateKey: false }), + new Promise((resolve) => setTimeout(() => resolve(null), 8_000)), + ]) + if (!result) return 0 + return result.interfaces.length + } catch { + return 0 + } +} + +export function getEnabledServerById(serverId: string | number): ServerRow | null { + const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10) + if (!Number.isFinite(id)) return null + return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null +} + +export { type RosWireGuard, type RosWireGuardPeer } diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 7f9f397..f3ad144 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -102,7 +102,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [ }, ] -type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number } +type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number } export function AppSidebar({ ...props }: React.ComponentProps) { const { mode, backendUrl, prefsHydrated } = useDataSource() @@ -165,8 +165,9 @@ export function AppSidebar({ ...props }: React.ComponentProps) { if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems) if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0) if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0) + if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0) - if (url === "/wireguard" || url === "/containers" || url === "/bgp") { + if (url === "/containers" || url === "/bgp") { return undefined } diff --git a/components/data-grids/wireguard-data-grid.tsx b/components/data-grids/wireguard-data-grid.tsx index e8e62fb..98a01aa 100644 --- a/components/data-grids/wireguard-data-grid.tsx +++ b/components/data-grids/wireguard-data-grid.tsx @@ -33,7 +33,6 @@ import { ChevronRightIcon, CodeXmlIcon, MoreHorizontalIcon, - PencilIcon, PlusIcon, PowerIcon, ShieldCheckIcon, @@ -49,9 +48,22 @@ export interface WgIfaceWithServer extends WireGuardInterface { interface WireguardDataGridProps { interfaces: WgIfaceWithServer[] onExport: (iface: WgIfaceWithServer) => void + onAddPeer?: (iface: WgIfaceWithServer) => void + onToggle?: (iface: WgIfaceWithServer) => void + onDelete?: (iface: WgIfaceWithServer) => void + onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void + onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void } -function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) { +function WireguardDataGrid({ + interfaces, + onExport, + onAddPeer, + onToggle, + onDelete, + onDeletePeer, + onExportPeer, +}: WireguardDataGridProps) { const columns = useMemo[]>( () => [ { @@ -82,7 +94,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) { {iface.name}
- + {iface.serverName}

@@ -97,7 +109,11 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST, expandedContent: (row: WgIfaceWithServer) => ( - + onDeletePeer(row, peerId) : undefined} + onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined} + /> ), }, }, @@ -203,26 +219,32 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) { onExport(iface)}> - Экспорт .rsc - - - - Редактировать - - - - Добавить пира - - - - - {iface.enabled ? "Отключить" : "Включить"} - - - - - Удалить + Экспорт + {onAddPeer && ( + onAddPeer(iface)}> + + Добавить пира + + )} + {onToggle && ( + <> + + onToggle(iface)}> + + {iface.enabled ? "Отключить" : "Включить"} + + + )} + {onDelete && ( + <> + + onDelete(iface)}> + + Удалить + + + )} @@ -236,7 +258,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) { }, }, ], - [onExport], + [onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer], ) const table = useReactTable({ diff --git a/components/data-grids/wireguard-peers-detail.tsx b/components/data-grids/wireguard-peers-detail.tsx index f98152f..bc4cb20 100644 --- a/components/data-grids/wireguard-peers-detail.tsx +++ b/components/data-grids/wireguard-peers-detail.tsx @@ -2,10 +2,13 @@ import type { WireGuardPeer } from "@/lib/data" import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" import { ArrowDownIcon, ArrowUpIcon, + CodeXmlIcon, KeyRoundIcon, + Trash2Icon, } from "lucide-react" function fmtBytes(n: number | undefined): string { @@ -21,7 +24,19 @@ function truncKey(key: string): string { return `${key.slice(0, 8)}…${key.slice(-8)}` } -function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) { +function peerKey(peer: WireGuardPeer, index: number): string { + return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index) +} + +function WireGuardPeersDetail({ + peers, + onDeletePeer, + onExportPeer, +}: { + peers: WireGuardPeer[] + onDeletePeer?: (peerId: string) => void + onExportPeer?: (peerId: string) => void +}) { if (peers.length === 0) { return (

@@ -32,48 +47,84 @@ function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) { return (
-
+
Public Key Allowed IPs Последнее рукопожатие RX / TX Endpoint + Действия
- {peers.map((peer) => ( -
-
- - - {truncKey(peer.publicKey)} - -
-
- {peer.allowedIps.join(", ")} -
- { + const id = peerKey(peer, index) + return ( +
- {peer.latestHandshake ?? "нет рукопожатия"} - -
- - - {fmtBytes(peer.transferRx)} - - - - {fmtBytes(peer.transferTx)} +
+ + + {truncKey(peer.publicKey)} + +
+
+ {peer.allowedIps.join(", ")} +
+ + {peer.latestHandshake ?? "нет рукопожатия"} +
+ + + {fmtBytes(peer.transferRx)} + + + + {fmtBytes(peer.transferTx)} + +
+ {peer.endpoint ?? "—"} +
+ {onExportPeer && ( + + )} + {onDeletePeer && ( + + )} +
- {peer.endpoint ?? "—"} -
- ))} + ) + })}
) } diff --git a/components/wireguard/wg-create-sheet.tsx b/components/wireguard/wg-create-sheet.tsx new file mode 100644 index 0000000..f83d519 --- /dev/null +++ b/components/wireguard/wg-create-sheet.tsx @@ -0,0 +1,232 @@ +"use client" + +import { useMemo, useState } from "react" +import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, + SheetDescription, SheetFooter, SheetClose, +} from "@/components/ui/sheet" +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react" + +export type WgCreateFormState = { + serverId: string + name: string + listenPort: string + mtu: string + comment: string + address: string + enabled: boolean + showAdvanced: boolean + peerEnabled: boolean + peerPublicKey: string + peerAllowedIps: string + peerEndpoint: string + peerKeepalive: string + peerComment: string +} + +export const defaultWgCreateForm = (): WgCreateFormState => ({ + serverId: "", + name: "", + listenPort: "13231", + mtu: "1420", + comment: "", + address: "", + enabled: true, + showAdvanced: false, + peerEnabled: false, + peerPublicKey: "", + peerAllowedIps: "", + peerEndpoint: "", + peerKeepalive: "25", + peerComment: "", +}) + +type ServerOption = { id: string; name: string; host: string } + +function WgCreateSheet({ + open, + onOpenChange, + servers, + busy, + onSubmit, +}: { + open: boolean + onOpenChange: (v: boolean) => void + servers: ServerOption[] + busy?: boolean + onSubmit: (form: WgCreateFormState) => void | Promise +}) { + const [form, setForm] = useState(defaultWgCreateForm) + const set = (k: K, v: WgCreateFormState[K]) => + setForm((f) => ({ ...f, [k]: v })) + + const canSubmit = useMemo(() => { + return Boolean(form.serverId && form.name.trim() && form.listenPort) + }, [form.serverId, form.name, form.listenPort]) + + return ( + { + if (v) setForm(defaultWgCreateForm()) + onOpenChange(v) + }} + > + + + Быстрый туннель WireGuard + + Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS) + + + +
+
+ Основные + + + + + set("name", e.target.value)} + /> + +
+ + set("listenPort", e.target.value)} + /> + + + set("mtu", e.target.value)} + /> + +
+ + set("comment", e.target.value)} + placeholder="MSK → SPB overlay" + /> + +
+
+

Включён

+

disabled=no на роутере

+
+ set("enabled", v)} /> +
+
+ + + + {form.showAdvanced && ( +
+ + set("address", e.target.value)} + /> + + +
+
+

Добавить первого пира

+

Сразу после создания интерфейса

+
+ set("peerEnabled", v)} /> +
+ + {form.peerEnabled && ( +
+ + set("peerPublicKey", e.target.value)} + /> + + + set("peerAllowedIps", e.target.value)} + /> + + + set("peerEndpoint", e.target.value)} + /> + + + set("peerKeepalive", e.target.value)} + /> + + + set("peerComment", e.target.value)} + /> + +
+ )} +
+ )} +
+ + + }> + Отмена + + + +
+
+ ) +} + +export { WgCreateSheet } diff --git a/components/wireguard/wg-export-sheet.tsx b/components/wireguard/wg-export-sheet.tsx new file mode 100644 index 0000000..4e65440 --- /dev/null +++ b/components/wireguard/wg-export-sheet.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid" +import { Button } from "@/components/ui/button" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, + SheetDescription, SheetFooter, SheetClose, +} from "@/components/ui/sheet" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + generateMikrotikRsc, + generateNativeConf, + generatePeerClientConf, +} from "@/lib/wg-config" +import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react" + +function downloadText(filename: string, content: string) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function WgExportSheet({ + open, + iface, + onClose, + liveContent, + liveBusy, + onRequestLiveExport, +}: { + open: boolean + iface: WgIfaceWithServer | null + onClose: () => void + /** Optional server-fetched content (with private key) keyed by format */ + liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null + liveBusy?: boolean + onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void +}) { + const [tab, setTab] = useState<"rsc" | "conf" | "peer">("rsc") + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (open) { + setTab("rsc") + setCopied(false) + } + }, [open, iface?.id]) + + const local = useMemo(() => { + if (!iface) return { rsc: "", conf: "", peerConf: "" } + const base = { + name: iface.name, + listenPort: iface.listenPort, + mtu: iface.mtu, + comment: iface.comment, + enabled: iface.enabled, + privateKey: iface.privateKey, + publicKey: iface.publicKey, + address: iface.address, + serverName: iface.serverName, + peers: iface.peers.map((p) => ({ + publicKey: p.publicKey, + allowedIps: p.allowedIps, + endpoint: p.endpoint, + persistentKeepalive: p.persistentKeepalive, + persistent: p.persistent, + comment: p.comment, + name: p.name, + clientAddress: p.clientAddress, + clientDns: p.clientDns, + clientEndpoint: p.clientEndpoint, + })), + } + const peer = iface.peers[0] + return { + rsc: generateMikrotikRsc(base), + conf: generateNativeConf(base), + peerConf: + iface.publicKey && peer + ? generatePeerClientConf({ + peerAddress: peer.clientAddress, + peerDns: peer.clientDns, + serverPublicKey: iface.publicKey, + allowedIps: peer.allowedIps, + endpoint: + peer.clientEndpoint || + peer.endpoint || + undefined, + persistentKeepalive: peer.persistentKeepalive ?? 25, + }) + : "# Нет public-key интерфейса или пиров для клиентского .conf\n", + } + }, [iface]) + + const code = + tab === "rsc" + ? (liveContent?.rsc ?? local.rsc) + : tab === "conf" + ? (liveContent?.conf ?? local.conf) + : (liveContent?.peerConf ?? local.peerConf) + + const filename = + tab === "rsc" + ? `${iface?.name ?? "wg"}.rsc` + : tab === "conf" + ? `${iface?.name ?? "wg"}.conf` + : `${iface?.name ?? "wg"}-peer.conf` + + function handleCopy() { + void navigator.clipboard.writeText(code).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( + { if (!v) onClose() }}> + + +
+
+ Экспорт WireGuard + + {iface ? `${iface.name} · ${iface.serverName}` : "—"} + +
+
+ + +
+
+
+ +
+ setTab(v as typeof tab)}> + + MikroTik .rsc + Native .conf + Peer .conf + + {onRequestLiveExport && ( +
+ +
+ )} + + + +
+
+ +
+
+            {code.split("\n").map((line, i) => {
+              const isComment = line.startsWith("#")
+              const isCmd = line.trimStart().startsWith("/interface") || line.trimStart().startsWith("/ip")
+              const isSection = line.startsWith("[")
+              const isParam = /^\s+[a-z]/.test(line) || /^[A-Za-z]+=/.test(line)
+              return (
+                
+                  {line}{"\n"}
+                
+              )
+            })}
+          
+
+ + + }>Закрыть + + +
+
+ ) +} + +export { WgExportSheet } diff --git a/components/wireguard/wg-import-sheet.tsx b/components/wireguard/wg-import-sheet.tsx new file mode 100644 index 0000000..9e993a4 --- /dev/null +++ b/components/wireguard/wg-import-sheet.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useMemo, useState } from "react" +import { FormField, SectionTitle } from "@/components/form-kit" +import { Button } from "@/components/ui/button" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, + SheetDescription, SheetFooter, SheetClose, +} from "@/components/ui/sheet" +import { detectWgConfigFormat, parseWgConfig, type WgParsedConfig } from "@/lib/wg-config" +import { UploadIcon } from "lucide-react" + +type ServerOption = { id: string; name: string; host: string } + +function WgImportSheet({ + open, + onOpenChange, + servers, + busy, + onImport, +}: { + open: boolean + onOpenChange: (v: boolean) => void + servers: ServerOption[] + busy?: boolean + onImport: (args: { + serverId: string + content: string + format: "auto" | "rsc" | "conf" + dryRun: boolean + }) => Promise +}) { + const [serverId, setServerId] = useState("") + const [content, setContent] = useState("") + const [format, setFormat] = useState<"auto" | "rsc" | "conf">("auto") + const [preview, setPreview] = useState(null) + const [parseError, setParseError] = useState(null) + + const detected = useMemo( + () => (content.trim() ? detectWgConfigFormat(content) : null), + [content], + ) + + function runPreview() { + setParseError(null) + setPreview(null) + try { + setPreview(parseWgConfig(content, format)) + } catch (e) { + setParseError(e instanceof Error ? e.message : "Ошибка разбора") + } + } + + function onFile(file: File | null) { + if (!file) return + const reader = new FileReader() + reader.onload = () => { + setContent(String(reader.result ?? "")) + setPreview(null) + setParseError(null) + } + reader.readAsText(file) + } + + return ( + { + if (!v) { + setContent("") + setPreview(null) + setParseError(null) + setServerId("") + } + onOpenChange(v) + }} + > + + + Импорт конфига WireGuard + + Native .conf или MikroTik .rsc → применить на выбранный роутер + + + +
+ + + + + + + + +
+ Содержимое + +