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 → применить на выбранный роутер + + + +
+ + + + + + + + +
+ Содержимое + +