Compare commits

...
5 Commits
Author SHA1 Message Date
Denozordec 15ad53af1f 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.
2026-09-05 02:10:55 +07:00
Denozordec 883842636b refactor(api): streamline API requests with requestJson and requestBlob functions
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
2026-09-05 01:42:44 +07:00
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 2m21s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
2026-09-05 00:07:58 +07:00
Denozordec f2df990746 chore(docker): enhance Dockerfile for backend to manage nested dependencies
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m23s
Docker images / frontend-image (push) Successful in 2m12s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Successful in 9s
Updated the Dockerfile to create a directory for nested workspace dependencies and adjusted the copy commands to ensure proper handling of node_modules during the build process.
2026-09-04 23:48:03 +07:00
39 changed files with 3376 additions and 425 deletions
+2 -2
View File
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
Stepper,
@@ -276,8 +277,7 @@ export default function BackupsPage() {
}
async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
+2 -5
View File
@@ -23,6 +23,7 @@ import {
XIcon, AlertCircleIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -621,11 +622,7 @@ export default function BgpPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(data => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
+10 -9
View File
@@ -16,6 +16,7 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Flag } from "@/components/flag"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
@@ -733,13 +734,14 @@ function InterfacesTab({
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const data = await requestJson<BackendOspfOptimizeResponse>(
backendUrl,
`/api/servers/${filterServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: ra.pingWeight }),
},
)
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
@@ -1120,8 +1122,7 @@ export default function OspfPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(data => {
if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
+4 -1
View File
@@ -875,8 +875,11 @@ export default function SettingsPage() {
await evo.saveSettings(patch)
setEvoKeyDraft("")
markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally {
setEvoSaveBusy(false)
}
+14 -12
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
@@ -259,12 +260,14 @@ function Terminal({
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
const data = await requestJson<{ output?: string; error?: string }>(
backendUrl,
`/api/servers/${server.backendId}/exec`,
{
method: "POST",
body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
@@ -427,7 +430,7 @@ interface BackendServer {
}
export default function TerminalPage() {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
// Server list state
@@ -437,14 +440,13 @@ export default function TerminalPage() {
// Load servers from backend when in live mode
useEffect(() => {
if (!isLive) return
if (!isLive || !prefsHydrated) return
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setServersLoading(true)
fetch(`${backendUrl}/api/servers`)
.then(r => r.json() as Promise<BackendServer[]>)
.then(data => {
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
.then((data) => {
if (cancelled) return
setLiveServers(data.map(s => ({
uid: String(s.id),
@@ -462,7 +464,7 @@ export default function TerminalPage() {
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
}, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
+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>
)
+5 -1
View File
@@ -31,7 +31,9 @@ COPY packages/contracts packages/contracts
COPY backend backend
RUN npm run build -w @mmapp/contracts \
&& npm run build -w mikrotik-manager-backend \
&& npm prune --omit=dev
&& npm prune --omit=dev \
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
&& mkdir -p backend/node_modules
FROM node:22-bookworm-slim AS runner
WORKDIR /app
@@ -45,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["node", "backend/dist/index.js"]
+2 -1
View File
@@ -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",
+2
View File
@@ -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()
+8
View File
@@ -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")
+4 -2
View File
@@ -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",
},
]
+19 -8
View File
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
}
}
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
function normalizeApiKey(raw: string): string {
const trimmed = raw.trim()
return trimmed.replace(/^Bearer\s+/i, "").trim()
}
interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> }
domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim()
const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null
return { root, apiKey }
}
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
if (parsed.data.baseUrl !== undefined) {
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
}
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
nextKey =
parsed.data.apiKey === null || parsed.data.apiKey === ""
? ""
: normalizeApiKey(parsed.data.apiKey)
}
db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim()
const token = normalizeApiKey(keyRaw)
if (!root || !token) {
return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
+4
View File
@@ -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,
})
})
}
+456
View File
@@ -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<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { 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
+30 -8
View File
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
)
}
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A")
if (existingA) {
if (existingA.content === ip) return
if (existingA.content === ip) return "updated"
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH",
body: JSON.stringify({
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false,
}),
})
return
return "updated"
}
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
return "skipped_cname"
}
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false,
}),
})
return "created"
}
async function syncCertificateDomainRecords(
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
domains: string[],
serverIp: string,
defaultZoneId?: string,
): Promise<void> {
): Promise<{ skippedCname: string[] }> {
const skippedCname: string[] = []
for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp)
const result = await upsertARecord(token, zoneId, domain, serverIp)
if (result === "skipped_cname") skippedCname.push(domain)
}
return { skippedCname }
}
async function sleep(ms: number) {
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos)
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
try {
params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
const { skippedCname } = await syncCertificateDomainRecords(
token,
domains,
serverIp,
settings.defaultZoneId,
)
if (skippedCname.length > 0) {
params.onStep?.(
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
params.onStep?.(`dns_a_sync_warn:${msg}`)
}
const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
@@ -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")
+359
View File
@@ -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<string, string> {
const out: Record<string, string> = {}
// 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")
}
+214
View File
@@ -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<WgIfaceDto[]> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const peersByIface = new Map<string, WgPeerDto[]>()
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<string, string>()
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<WgListResult> {
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<number> {
try {
const result = await Promise.race([
listWireGuardInterfaces({ includePrivateKey: false }),
new Promise<null>((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 }
+14 -21
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import {
formatSidebarBadgeCount,
mockSidebarBadgesByUrl,
@@ -101,10 +102,10 @@ 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<typeof Sidebar>) {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [])
React.useEffect(() => {
if (mode !== "live") {
setLiveCounts(null)
if (!prefsHydrated || mode !== "live") {
if (mode !== "live") setLiveCounts(null)
return
}
let cancelled = false
const load = async () => {
try {
const base = backendUrl.replace(/\/$/, "")
const [cRes, gRes] = await Promise.all([
fetch(`${base}/api/sidebar-counts`),
fetch(`${base}/api/filters/gre-tunnels`),
const [cJson, gJson] = await Promise.all([
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
() => ({ tunnels: [] as unknown[] }),
),
])
if (cancelled) return
if (!cRes.ok) {
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
} catch {
if (!cancelled) setLiveCounts(null)
}
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl])
}, [mode, backendUrl, prefsHydrated])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
@@ -173,8 +165,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
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
}
+46 -24
View File
@@ -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<ColumnDef<WgIfaceWithServer>[]>(
() => [
{
@@ -82,7 +94,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
<Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName}
</div>
<p className="sr-only">
@@ -97,7 +109,11 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail peers={row.peers} />
<WireGuardPeersDetail
peers={row.peers}
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
/>
),
},
},
@@ -203,26 +219,32 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" />
Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
Экспорт
</DropdownMenuItem>
{onAddPeer && (
<DropdownMenuItem onClick={() => onAddPeer(iface)}>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
)}
{onToggle && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(iface)}>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(iface)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -236,7 +258,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
},
},
],
[onExport],
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
)
const table = useReactTable({
@@ -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 (
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
@@ -32,48 +47,84 @@ function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
return (
<div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
<span className="sr-only">Действия</span>
</div>
{peers.map((peer) => (
<div
key={peer.publicKey}
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
{peers.map((peer, index) => {
const id = peerKey(peer, index)
return (
<div
key={id}
className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
<div className="flex items-center gap-1 justify-end">
{onExportPeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={(e) => {
e.stopPropagation()
onExportPeer(id)
}}
>
<CodeXmlIcon className="size-3.5" />
</Button>
)}
{onDeletePeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={(e) => {
e.stopPropagation()
onDeletePeer(id)
}}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</div>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
))}
)
})}
</div>
)
}
+8 -11
View File
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { filters, pingProbes, servers } from "@/lib/data"
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
type MonitorMetric = {
id: string
@@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
useEffect(() => {
if (!prefsHydrated) return
if (mode !== "live") {
setHealthOk(true)
setCounts({
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
let cancelled = false
const load = async () => {
const base = backendUrl.replace(/\/$/, "")
try {
const [hRes, cRes] = await Promise.all([
fetch(`${base}/health`),
fetch(`${base}/api/sidebar-counts`),
const [hRes, counts] = await Promise.all([
fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
])
if (cancelled) return
if (hRes.ok) {
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
} else {
setHealthOk(false)
}
if (cRes.ok) {
setCounts((await cRes.json()) as SidebarCountsDto)
} else {
setCounts(null)
}
setCounts(counts)
} catch {
if (!cancelled) {
setHealthOk(false)
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl])
}, [mode, backendUrl, prefsHydrated])
const serversCount = counts?.servers ?? 0
const filtersCount = counts?.filterRules ?? 0
+232
View File
@@ -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<void>
}) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(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 (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
<SheetDescription>
Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS)
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.serverId}
onChange={(e) => set("serverId", e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Имя интерфейса" required hint="Например wg-msk-spb">
<Input
className="font-mono"
placeholder="wg0"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Listen port" required>
<Input
className="font-mono"
value={form.listenPort}
onChange={(e) => set("listenPort", e.target.value)}
/>
</FormField>
<FormField label="MTU">
<Input
className="font-mono"
value={form.mtu}
onChange={(e) => set("mtu", e.target.value)}
/>
</FormField>
</div>
<FormField label="Комментарий">
<Input
value={form.comment}
onChange={(e) => set("comment", e.target.value)}
placeholder="MSK → SPB overlay"
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Включён</p>
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
</div>
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => set("showAdvanced", !form.showAdvanced)}
>
{form.showAdvanced ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
Дополнительно
</button>
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<FormField label="IP на интерфейсе" hint="/ip address add, например 10.210.0.1/30">
<Input
className="font-mono"
placeholder="10.210.0.1/30"
value={form.address}
onChange={(e) => set("address", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Добавить первого пира</p>
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
</div>
<FormToggle checked={form.peerEnabled} onChange={(v) => set("peerEnabled", v)} />
</div>
{form.peerEnabled && (
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
<FormField label="Public key пира" required>
<Input
className="font-mono text-xs"
value={form.peerPublicKey}
onChange={(e) => set("peerPublicKey", e.target.value)}
/>
</FormField>
<FormField label="Allowed IPs" required hint="Через запятую">
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.peerAllowedIps}
onChange={(e) => set("peerAllowedIps", e.target.value)}
/>
</FormField>
<FormField label="Endpoint" hint="host:port">
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.peerEndpoint}
onChange={(e) => set("peerEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.peerKeepalive}
onChange={(e) => set("peerKeepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий пира">
<Input
value={form.peerComment}
onChange={(e) => set("peerComment", e.target.value)}
/>
</FormField>
</div>
)}
</div>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
>
{busy ? "Создание…" : "Создать туннель"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgCreateSheet }
+219
View File
@@ -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 (
<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>
{iface ? `${iface.name} · ${iface.serverName}` : "—"}
</SheetDescription>
</div>
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
</div>
</div>
</SheetHeader>
<div className="px-6 pt-3 shrink-0">
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<TabsList>
<TabsTrigger value="rsc">MikroTik .rsc</TabsTrigger>
<TabsTrigger value="conf">Native .conf</TabsTrigger>
<TabsTrigger value="peer">Peer .conf</TabsTrigger>
</TabsList>
{onRequestLiveExport && (
<div className="mt-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={liveBusy}
onClick={() =>
onRequestLiveExport(
tab === "peer" ? "peer-conf" : tab === "conf" ? "conf" : "rsc",
)
}
>
{liveBusy ? "Загрузка с роутера…" : "Подтянуть с роутера (с private-key)"}
</Button>
</div>
)}
<TabsContent value="rsc" className="mt-0" />
<TabsContent value="conf" className="mt-0" />
<TabsContent value="peer" className="mt-0" />
</Tabs>
</div>
<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") || line.trimStart().startsWith("/ip")
const isSection = line.startsWith("[")
const isParam = /^\s+[a-z]/.test(line) || /^[A-Za-z]+=/.test(line)
return (
<span
key={i}
className={
isComment
? "text-muted-foreground"
: isCmd || isSection
? "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 ? "Скопировано" : "Копировать"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgExportSheet }
+182
View File
@@ -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<void>
}) {
const [serverId, setServerId] = useState("")
const [content, setContent] = useState("")
const [format, setFormat] = useState<"auto" | "rsc" | "conf">("auto")
const [preview, setPreview] = useState<WgParsedConfig | null>(null)
const [parseError, setParseError] = useState<string | null>(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 (
<Sheet
open={open}
onOpenChange={(v) => {
if (!v) {
setContent("")
setPreview(null)
setParseError(null)
setServerId("")
}
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Импорт конфига WireGuard</SheetTitle>
<SheetDescription>
Native .conf или MikroTik .rsc применить на выбранный роутер
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Формат">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={format}
onChange={(e) => setFormat(e.target.value as "auto" | "rsc" | "conf")}
>
<option value="auto">Авто{detected ? ` (${detected})` : ""}</option>
<option value="conf">Native WireGuard (.conf)</option>
<option value="rsc">MikroTik (.rsc)</option>
</select>
</FormField>
<div className="flex flex-col gap-2">
<SectionTitle>Содержимое</SectionTitle>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer w-fit">
<UploadIcon className="size-4" />
Загрузить файл
<input
type="file"
accept=".conf,.rsc,.txt,text/plain"
className="sr-only"
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
</label>
<textarea
className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
placeholder={"[Interface]\nPrivateKey = …\n…\n\nили\n\n/interface wireguard add …"}
value={content}
onChange={(e) => {
setContent(e.target.value)
setPreview(null)
setParseError(null)
}}
/>
</div>
<Button type="button" variant="outline" size="sm" onClick={runPreview} disabled={!content.trim()}>
Предпросмотр
</Button>
{parseError && <p className="text-sm text-destructive">{parseError}</p>}
{preview && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm flex flex-col gap-2">
<p className="font-medium">
{preview.format.toUpperCase()} · {preview.interface.name}
</p>
<p className="text-xs text-muted-foreground font-mono">
port={preview.interface.listenPort ?? "—"} · mtu={preview.interface.mtu ?? "—"}
{preview.interface.address ? ` · ${preview.interface.address}` : ""}
</p>
<p className="text-xs text-muted-foreground">Пиров: {preview.peers.length}</p>
{preview.peers.slice(0, 5).map((p, i) => (
<p key={i} className="text-[11px] font-mono text-muted-foreground truncate">
{p.publicKey.slice(0, 16)} {p.allowedAddresses.join(", ")}
</p>
))}
</div>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
<div className="flex w-full gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!serverId || !content.trim() || busy}
onClick={() => void onImport({ serverId, content, format, dryRun: false })}
>
{busy ? "Импорт…" : "Применить на роутер"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgImportSheet }
+114
View File
@@ -0,0 +1,114 @@
"use client"
import { useState } from "react"
import { FormField, 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 type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
export type WgPeerFormState = {
publicKey: string
allowedIps: string
endpoint: string
keepalive: string
comment: string
}
const emptyPeerForm = (): WgPeerFormState => ({
publicKey: "",
allowedIps: "",
endpoint: "",
keepalive: "25",
comment: "",
})
function WgPeerSheet({
open,
iface,
busy,
onOpenChange,
onSubmit,
}: {
open: boolean
iface: WgIfaceWithServer | null
busy?: boolean
onOpenChange: (v: boolean) => void
onSubmit: (form: WgPeerFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgPeerFormState>(emptyPeerForm)
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(emptyPeerForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Добавить пира</SheetTitle>
<SheetDescription>
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4">
<SectionTitle>Параметры пира</SectionTitle>
<FormField label="Public key" required>
<Input
className="font-mono text-xs"
value={form.publicKey}
onChange={(e) => set("publicKey", e.target.value)}
/>
</FormField>
<FormField label="Allowed IPs" required hint="Через запятую">
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.allowedIps}
onChange={(e) => set("allowedIps", e.target.value)}
/>
</FormField>
<FormField label="Endpoint" hint="host:port">
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.endpoint}
onChange={(e) => set("endpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.keepalive}
onChange={(e) => set("keepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий">
<Input value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
onClick={() => void onSubmit(form)}
>
{busy ? "Сохранение…" : "Добавить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgPeerSheet }
+15 -1
View File
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
return cfg.kind === "same-origin" || cfg.kind === "fixed"
}
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1"
}
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
export function resolveStoredBackendUrl(stored: string | null): string {
const cfg = configuredBackendUrl()
if (cfg.kind === "fixed") return cfg.url
if (cfg.kind === "same-origin" && typeof window !== "undefined") {
if (cfg.kind === "same-origin") {
if (typeof window !== "undefined") return window.location.origin
return ""
}
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
const trimmed = stored?.trim().replace(/\/$/, "")
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
}
return trimmed || LOCAL_DEFAULT_BACKEND_URL
}
+11 -11
View File
@@ -9,6 +9,7 @@ import {
LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl,
} from "@/lib/backend-url"
import { resolveApiUrl } from "@/shared/api/http-client"
// ── types ─────────────────────────────────────────────────────────────────────
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
return defaultDataSourceMode()
}
function readStoredBackendUrl(): string {
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
function initialBackendUrl(): string {
if (typeof window === "undefined") {
const cfg = configuredBackendUrl()
if (cfg.kind === "same-origin") return ""
if (cfg.kind === "fixed") return cfg.url
return LOCAL_DEFAULT_BACKEND_URL
}
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
}
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
const [prefsHydrated, setPrefsHydrated] = useState(false)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
useEffect(() => {
const storedMode = readStoredMode()
let url = readStoredBackendUrl()
if (configuredBackendUrl().kind === "same-origin") {
url = window.location.origin
}
const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
setModeState(storedMode)
setBackendUrlState(url)
setPrefsHydrated(true)
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked])
const checkBackend = useCallback(async () => {
const healthUrl =
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
const healthUrl = resolveApiUrl(backendUrl, "/health")
try {
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok)
+12
View File
@@ -14,21 +14,33 @@ export interface WanUplink {
// ─── WireGuard ───────────────────────────────────────────────────────────────
export interface WireGuardPeer {
id?: string
rosId?: string
publicKey: string
allowedIps: string[]
endpoint?: string // "1.2.3.4:13231"
latestHandshake?: string // "2 минуты назад"
transferRx?: number // bytes
transferTx?: number // bytes
persistentKeepalive?: number
persistent?: boolean
comment?: string
disabled?: boolean
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}
export interface WireGuardInterface {
id: string
rosId?: string
name: string // e.g. "wg-msk-spb"
listenPort: number // default 13231
mtu: number // 1420 default in ROS 7.x
publicKey?: string
privateKey?: string
address?: string
peers: WireGuardPeer[]
comment: string
enabled: boolean
+56 -57
View File
@@ -10,6 +10,7 @@ import {
} from "react"
import { useDataSource } from "@/lib/data-source"
import type { Domain, IpRange, Asn } from "@/lib/data"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
export interface EvoBgpCommunityRow {
id: string
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
function errorMessage(e: unknown, fallback: string): string {
if (e instanceof ApiClientError) return e.message || fallback
if (e instanceof Error) return e.message || fallback
return fallback
}
export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const { mode, backendUrl, backendStatus } = useDataSource()
const [baseUrl, setBaseUrlState] = useState("")
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, {
method: "POST",
})
const text = await res.text()
if (!res.ok) {
let msg = res.statusText
try {
const j = JSON.parse(text) as { error?: string; detail?: string }
msg = j.error ?? j.detail ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Ошибка EvoBGP")
}
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
const data = await requestJson<EvoBgpCatalogSnapshot>(
backendUrl,
"/api/evobgp/catalog",
{ method: "POST" },
)
setSnapshot(data)
} catch (e) {
setSnapshot(null)
setError(e instanceof Error ? e.message : "Ошибка загрузки")
setError(errorMessage(e, "Ошибка загрузки"))
} finally {
setLoading(false)
}
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
return
}
try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`)
if (!res.ok) throw new Error(await res.text())
const data = (await res.json()) as EvoBgpSettingsDto
const data = await requestJson<EvoBgpSettingsDto>(
backendUrl,
"/api/evobgp/settings",
)
setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false)
setSecretConfigured(data.secretConfigured ?? false)
setEnabledState(Boolean(data.enabled))
setSecretConfigured(Boolean(data.secretConfigured))
setSettingsLoaded(true)
await pullCatalog(data.enabled ?? false)
} catch {
setError(null)
await pullCatalog(Boolean(data.enabled))
} catch (e) {
setSettingsLoaded(true)
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
}
}, [mode, backendStatus, backendUrl, pullCatalog])
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const saveSettings = useCallback(
async (patch: EvoBgpSavePayload) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
})
const text = await res.text()
if (!res.ok) {
let msg = res.statusText
try {
const j = JSON.parse(text) as { error?: string }
msg = j.error ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Не удалось сохранить")
}
const data = JSON.parse(text) as EvoBgpSettingsDto
const data = await requestJson<EvoBgpSettingsDto>(
backendUrl,
"/api/evobgp/settings",
{
method: "PUT",
body: JSON.stringify(patch),
},
)
const nextEnabled = Boolean(data.enabled)
setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false)
setSecretConfigured(data.secretConfigured ?? false)
await pullCatalog(data.enabled ?? false)
setEnabledState(nextEnabled)
setSecretConfigured(Boolean(data.secretConfigured))
setError(null)
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
try {
await pullCatalog(nextEnabled)
} catch {
/* pullCatalog already sets error state */
}
},
[backendUrl, pullCatalog],
)
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
await pullCatalog(enabled)
}, [enabled, pullCatalog])
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => {
try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft ?? {}),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
if (!res.ok) throw new Error(data.error ?? res.statusText)
return { ok: true, message: "Соединение с EvoBGP установлено" }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" }
}
}, [backendUrl])
const testConnection = useCallback(
async (draft?: EvoBgpTestDraft) => {
try {
await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
method: "POST",
body: JSON.stringify(draft ?? {}),
})
return { ok: true, message: "Соединение с EvoBGP установлено" }
} catch (e) {
return { ok: false, message: errorMessage(e, "Ошибка") }
}
},
[backendUrl],
)
const value = useMemo(
() => ({
+2
View File
@@ -52,4 +52,6 @@ export interface SidebarCountsDto {
uptimeSpeedProbes: number
monitoringItems: number
recursiveRoutes: number
certificates?: number
wireguard?: number
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Client-side WireGuard config codecs (mirror of backend wireguard-config).
* Used for mock preview / offline export without hitting the API.
*/
export type WgParsedPeer = {
publicKey: string
allowedAddresses: string[]
endpointAddress?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
name?: string
privateKey?: 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<string, string> {
const out: Record<string, string> = {}
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 {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
}
}
}
flushPeer()
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
}
}
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): string {
const lines: string[] = []
lines.push(`[Interface]`)
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 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 \\`)
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")
}
export function generatePeerClientConf(args: {
peerAddress?: string
peerDns?: string
serverPublicKey: string
allowedIps?: string[]
endpoint?: string
persistentKeepalive?: number
}): string {
const lines: string[] = []
lines.push(`[Interface]`)
lines.push(`# 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")
}
+4
View File
@@ -33,6 +33,10 @@
"./backups": {
"types": "./dist/backups.d.ts",
"default": "./dist/backups.js"
},
"./wireguard": {
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
}
},
"dependencies": {
+1
View File
@@ -3,3 +3,4 @@ export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
+171
View File
@@ -0,0 +1,171 @@
import { z } from "zod"
export const wgStatusSchema = z.enum(["up", "down"])
export const wgPeerDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
publicKey: z.string(),
allowedIps: z.array(z.string()),
endpoint: z.string().optional(),
latestHandshake: z.string().optional(),
transferRx: z.number().nonnegative().optional(),
transferTx: z.number().nonnegative().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
persistent: z.boolean().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
name: z.string().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgIfaceDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
name: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
serverCountry: z.string().optional(),
listenPort: z.number().int().positive(),
mtu: z.number().int().positive(),
publicKey: z.string().optional(),
privateKey: z.string().optional(),
address: z.string().optional(),
peers: z.array(wgPeerDtoSchema),
comment: z.string(),
enabled: z.boolean(),
status: wgStatusSchema,
})
export const wgListResponseSchema = z.object({
interfaces: z.array(wgIfaceDtoSchema),
failures: z
.array(
z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
}),
)
.optional(),
})
export const wgCreatePeerSchema = z.object({
publicKey: z.string().min(1),
allowedAddresses: z.array(z.string().min(1)).min(1),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
privateKey: z.enum(["auto", "none"]).or(z.string().min(1)).optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
disabled: z.boolean().optional(),
})
export const wgCreateInterfaceSchema = z.object({
serverId: z.union([z.string(), z.number()]),
name: z.string().min(1).max(64),
listenPort: z.number().int().positive().default(13231),
mtu: z.number().int().positive().default(1420),
comment: z.string().optional(),
privateKey: z.string().min(1).optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
peer: wgCreatePeerSchema.optional(),
})
export const wgPatchInterfaceSchema = z.object({
name: z.string().min(1).max(64).optional(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
privateKey: z.string().min(1).optional(),
})
export const wgCreatePeerRequestSchema = wgCreatePeerSchema.extend({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
})
export const wgPatchPeerSchema = z.object({
publicKey: z.string().min(1).optional(),
allowedAddresses: z.array(z.string().min(1)).min(1).optional(),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgImportFormatSchema = z.enum(["auto", "rsc", "conf"])
export const wgImportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
content: z.string().min(1),
format: wgImportFormatSchema.optional().default("auto"),
dryRun: z.boolean().optional().default(false),
})
export const wgExportFormatSchema = z.enum(["rsc", "conf", "peer-conf"])
export const wgExportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
format: wgExportFormatSchema,
peerId: z.string().optional(),
includePrivateKey: z.boolean().optional().default(false),
})
export const wgImportPreviewSchema = z.object({
format: z.enum(["rsc", "conf"]),
interface: z.object({
name: z.string(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
privateKey: z.string().optional(),
comment: z.string().optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
}),
peers: z.array(wgCreatePeerSchema),
})
export const wgImportResponseSchema = z.object({
dryRun: z.boolean(),
preview: wgImportPreviewSchema,
applied: z
.object({
interfaceName: z.string(),
peersCreated: z.number().int().nonnegative(),
})
.optional(),
})
export const wgExportResponseSchema = z.object({
format: wgExportFormatSchema,
filename: z.string(),
content: z.string(),
})
export type WgPeerDto = z.infer<typeof wgPeerDtoSchema>
export type WgIfaceDto = z.infer<typeof wgIfaceDtoSchema>
export type WgListResponse = z.infer<typeof wgListResponseSchema>
export type WgCreateInterface = z.infer<typeof wgCreateInterfaceSchema>
export type WgPatchInterface = z.infer<typeof wgPatchInterfaceSchema>
export type WgCreatePeerRequest = z.infer<typeof wgCreatePeerRequestSchema>
export type WgPatchPeer = z.infer<typeof wgPatchPeerSchema>
export type WgImportRequest = z.infer<typeof wgImportRequestSchema>
export type WgExportRequest = z.infer<typeof wgExportRequestSchema>
export type WgImportPreview = z.infer<typeof wgImportPreviewSchema>
export type WgImportResponse = z.infer<typeof wgImportResponseSchema>
export type WgExportResponse = z.infer<typeof wgExportResponseSchema>
+64 -12
View File
@@ -21,38 +21,72 @@ function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveRequestUrl(baseUrl: string, path: string): string {
/** Absolute or same-origin-relative URL for backend API paths. */
export function resolveApiUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path
}
// Safety: never call browser localhost when the UI is served from a remote host
if (typeof window !== "undefined") {
const host = window.location.hostname
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
const baseIsLocal =
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
return path.startsWith("/") ? path : `/${path}`
}
}
return trimBaseUrl(baseUrl) + path
}
/** Attach portal JWT when present. */
export function withAuthHeaders(init?: HeadersInit): Headers {
const headers = new Headers(init)
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
return headers
}
function handleUnauthorized(): never {
if (typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
}
throw new ApiClientError("Unauthorized", 401)
}
async function parseErrorMessage(res: Response): Promise<string> {
const payload = await res.json().catch(() => undefined)
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
) {
return (payload as { error: string }).error
}
return res.statusText || `HTTP ${res.status}`
}
export async function requestJson<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const hasBody = init?.body != null
const headers = new Headers(init?.headers)
const headers = withAuthHeaders(init?.headers)
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
const res = await fetch(resolveRequestUrl(baseUrl, path), {
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
throw new ApiClientError("Unauthorized", 401)
}
if (res.status === 401) handleUnauthorized()
if (res.status === 204) return undefined as T
@@ -70,3 +104,21 @@ export async function requestJson<T>(
return payload as T
}
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
export async function requestBlob(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<Response> {
const headers = withAuthHeaders(init?.headers)
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (!res.ok) {
throw new ApiClientError(await parseErrorMessage(res), res.status)
}
return res
}
+3 -37
View File
@@ -1,19 +1,7 @@
import { ApiClientError } from "@/shared/api/http-client"
import { configuredBackendUrl } from "@/lib/backend-url"
import { ApiClientError, requestBlob } from "@/shared/api/http-client"
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
if (configuredBackendUrl().kind === "same-origin") {
return path
}
return `${trimBaseUrl(baseUrl)}${path}`
}
function parseFilename(contentDisposition: string | null, fallback: string): string {
if (!contentDisposition) return fallback
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
@@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
export async function downloadSystemDatabaseBackup(
baseUrl: string,
): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup"))
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
const res = await requestBlob(baseUrl, "/api/system/database/backup")
const blob = await res.blob()
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
return { blob, filename }
@@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File):
413,
)
}
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
await requestBlob(baseUrl, "/api/system/database/restore", {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file,
})
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
}
+107
View File
@@ -0,0 +1,107 @@
import type {
WgCreateInterface,
WgCreatePeerRequest,
WgExportRequest,
WgExportResponse,
WgImportRequest,
WgImportResponse,
WgListResponse,
WgPatchInterface,
WgPatchPeer,
} from "@mmapp/contracts/wireguard"
import { requestJson } from "@/shared/api/http-client"
export async function listWireGuard(
baseUrl: string,
opts?: { serverId?: string; includePrivateKey?: boolean },
): Promise<WgListResponse> {
const q = new URLSearchParams()
if (opts?.serverId) q.set("serverId", opts.serverId)
if (opts?.includePrivateKey) q.set("includePrivateKey", "1")
const qs = q.toString()
return requestJson<WgListResponse>(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`)
}
export async function createWireGuardInterface(
baseUrl: string,
payload: WgCreateInterface,
): Promise<unknown> {
return requestJson(baseUrl, "/api/wireguard/interfaces", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function patchWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchInterface,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function createWireGuardPeer(
baseUrl: string,
payload: WgCreatePeerRequest,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, "/api/wireguard/peers", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function patchWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchPeer,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function importWireGuard(
baseUrl: string,
payload: WgImportRequest,
): Promise<WgImportResponse> {
return requestJson(baseUrl, "/api/wireguard/import", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function exportWireGuard(
baseUrl: string,
payload: WgExportRequest,
): Promise<WgExportResponse> {
return requestJson(baseUrl, "/api/wireguard/export", {
method: "POST",
body: JSON.stringify(payload),
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long