feat(wireguard): implement WireGuard interface management and permissions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / publish-release (push) Successful in 10s

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.
This commit is contained in:
Denozordec
2026-09-05 02:10:55 +07:00
parent 883842636b
commit 15ad53af1f
25 changed files with 3122 additions and 231 deletions
+455 -167
View File
@@ -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 (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт WireGuard</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface")
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
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<WgIfaceWithServer | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(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<BackendServer[]>(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 (
<div className="flex flex-col h-full">
@@ -160,8 +395,24 @@ export default function WireGuardPage() {
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<Button size="sm">
<PlusIcon className="size-4" />Новый интерфейс
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
</>
}
@@ -169,14 +420,12 @@ export default function WireGuardPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
@@ -192,19 +441,20 @@ export default function WireGuardPage() {
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard рекомендуемый туннельный протокол в RouterOS 7.x</p>
<p className="font-medium text-sky-600 dark:text-sky-400">
WireGuard live-интеграция RouterOS 7.x
</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
{isLive
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
</p>
</div>
</div>
{/* Search + table */}
<DataPageCard>
<DataPageToolbar
search={search}
@@ -214,63 +464,101 @@ export default function WireGuardPage() {
/>
<WireguardDataGrid
interfaces={filtered}
onExport={setExportIface}
onExport={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
/>
</DataPageCard>
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
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) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
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) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title}
</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</div>
</div>
<ExportSheet
<WgCreateSheet
open={createOpen}
onOpenChange={setCreateOpen}
servers={serverOptions}
busy={busy}
onSubmit={handleCreate}
/>
<WgImportSheet
open={importOpen}
onOpenChange={setImportOpen}
servers={serverOptions}
busy={busy}
onImport={handleImport}
/>
<WgPeerSheet
open={!!peerIface}
iface={peerIface}
busy={busy}
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
onSubmit={handleAddPeer}
/>
<WgExportSheet
open={!!exportIface}
iface={exportIface}
onClose={() => setExportIface(null)}
onClose={() => {
setExportIface(null)
setLiveExport(null)
}}
liveContent={liveExport}
liveBusy={exportBusy}
onRequestLiveExport={isLive ? handleLiveExport : undefined}
/>
</div>
)