Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd |
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import { listServers } from "@/shared/api/servers"
|
import { listServers } from "@/shared/api/servers"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
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 { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
Stepper,
|
Stepper,
|
||||||
@@ -276,8 +277,7 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(id: string, fallbackFilename: string) {
|
async function handleDownload(id: string, fallbackFilename: string) {
|
||||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
|
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||||
if (!res.ok) throw new Error("Не удалось скачать файл")
|
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
XIcon, AlertCircleIcon,
|
XIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -621,11 +622,7 @@ export default function BgpPage() {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLiveError(null)
|
setLiveError(null)
|
||||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||||||
.then(r => {
|
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
||||||
return r.json() as Promise<BackendBgpSession[]>
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveSessions(data.map(backendToFrontend))
|
setLiveSessions(data.map(backendToFrontend))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||||
|
|
||||||
@@ -733,13 +734,14 @@ function InterfacesTab({
|
|||||||
const ra = readStoredRouteOptimizerSettings()
|
const ra = readStoredRouteOptimizerSettings()
|
||||||
setOptimizing(true)
|
setOptimizing(true)
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
|
const data = await requestJson<BackendOspfOptimizeResponse>(
|
||||||
method: "POST",
|
backendUrl,
|
||||||
headers: { "Content-Type": "application/json" },
|
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
{
|
||||||
})
|
method: "POST",
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||||
const data = await r.json() as BackendOspfOptimizeResponse
|
},
|
||||||
|
)
|
||||||
const byKey: Record<string, number> = {}
|
const byKey: Record<string, number> = {}
|
||||||
data.interfaces.forEach((row) => {
|
data.interfaces.forEach((row) => {
|
||||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||||
@@ -1120,8 +1122,7 @@ export default function OspfPage() {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLiveError(null)
|
setLiveError(null)
|
||||||
fetch(`${backendUrl}/api/ospf/all`)
|
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
|
||||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||||
|
|||||||
@@ -875,8 +875,11 @@ export default function SettingsPage() {
|
|||||||
await evo.saveSettings(patch)
|
await evo.saveSettings(patch)
|
||||||
setEvoKeyDraft("")
|
setEvoKeyDraft("")
|
||||||
markSaved()
|
markSaved()
|
||||||
|
toast.success("Настройки EvoBGP сохранены")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
|
||||||
|
setEvoSaveErr(msg)
|
||||||
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setEvoSaveBusy(false)
|
setEvoSaveBusy(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -259,12 +260,14 @@ function Terminal({
|
|||||||
if (isLive && server.backendId !== null) {
|
if (isLive && server.backendId !== null) {
|
||||||
setExecuting(true)
|
setExecuting(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
const data = await requestJson<{ output?: string; error?: string }>(
|
||||||
method: "POST",
|
backendUrl,
|
||||||
headers: { "Content-Type": "application/json" },
|
`/api/servers/${server.backendId}/exec`,
|
||||||
body: JSON.stringify({ command: cmd }),
|
{
|
||||||
})
|
method: "POST",
|
||||||
const data = await res.json() as { output?: string; error?: string }
|
body: JSON.stringify({ command: cmd }),
|
||||||
|
},
|
||||||
|
)
|
||||||
const text = data.output ?? data.error ?? "(empty response)"
|
const text = data.output ?? data.error ?? "(empty response)"
|
||||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||||
text.split("\n").forEach(line =>
|
text.split("\n").forEach(line =>
|
||||||
@@ -427,7 +430,7 @@ interface BackendServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TerminalPage() {
|
export default function TerminalPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
// Server list state
|
// Server list state
|
||||||
@@ -437,14 +440,13 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
// Load servers from backend when in live mode
|
// Load servers from backend when in live mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive || !prefsHydrated) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setServersLoading(true)
|
setServersLoading(true)
|
||||||
fetch(`${backendUrl}/api/servers`)
|
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||||
.then(r => r.json() as Promise<BackendServer[]>)
|
.then((data) => {
|
||||||
.then(data => {
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveServers(data.map(s => ({
|
setLiveServers(data.map(s => ({
|
||||||
uid: String(s.id),
|
uid: String(s.id),
|
||||||
@@ -462,7 +464,7 @@ export default function TerminalPage() {
|
|||||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isLive, backendUrl, refreshKey])
|
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||||
|
|
||||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||||
|
|
||||||
|
|||||||
+455
-167
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { servers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import type { WireGuardInterface } from "@/lib/data"
|
import type { Server } from "@/lib/data"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import {
|
import {
|
||||||
@@ -14,22 +14,32 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
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 {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
createWireGuardInterface,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
createWireGuardPeer,
|
||||||
} from "@/components/ui/sheet"
|
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 {
|
import {
|
||||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||||
CodeXmlIcon, UsersIcon, ActivityIcon,
|
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon,
|
||||||
CopyIcon, CheckIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
function collectMockInterfaces(): WgIfaceWithServer[] {
|
||||||
|
|
||||||
function collectInterfaces(): WgIfaceWithServer[] {
|
|
||||||
const result: WgIfaceWithServer[] = []
|
const result: WgIfaceWithServer[] = []
|
||||||
for (const srv of servers) {
|
for (const srv of mockServers) {
|
||||||
for (const wg of srv.wireGuardIfaces ?? []) {
|
for (const wg of srv.wireGuardIfaces ?? []) {
|
||||||
result.push({
|
result.push({
|
||||||
...wg,
|
...wg,
|
||||||
@@ -42,117 +52,342 @@ function collectInterfaces(): WgIfaceWithServer[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
function dtoToRow(d: WgIfaceDto): WgIfaceWithServer {
|
||||||
|
return {
|
||||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
id: d.id,
|
||||||
|
rosId: d.rosId,
|
||||||
function generateWgRsc(iface: WgIfaceWithServer): string {
|
name: d.name,
|
||||||
const lines: string[] = []
|
listenPort: d.listenPort,
|
||||||
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
|
mtu: d.mtu,
|
||||||
lines.push(`# RouterOS 7.x`)
|
publicKey: d.publicKey,
|
||||||
lines.push(``)
|
privateKey: d.privateKey,
|
||||||
lines.push(`/interface wireguard add \\`)
|
address: d.address,
|
||||||
lines.push(` name=${iface.name} \\`)
|
peers: d.peers.map((p) => ({
|
||||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
id: p.id,
|
||||||
lines.push(` mtu=${iface.mtu} \\`)
|
rosId: p.rosId,
|
||||||
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
|
publicKey: p.publicKey,
|
||||||
if (!iface.enabled) lines.push(` disabled=yes \\`)
|
allowedIps: p.allowedIps,
|
||||||
lines.push(``)
|
endpoint: p.endpoint,
|
||||||
for (const p of iface.peers) {
|
latestHandshake: p.latestHandshake,
|
||||||
lines.push(`/interface wireguard peers add \\`)
|
transferRx: p.transferRx,
|
||||||
lines.push(` interface=${iface.name} \\`)
|
transferTx: p.transferTx,
|
||||||
lines.push(` public-key="${p.publicKey}" \\`)
|
persistentKeepalive: p.persistentKeepalive,
|
||||||
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
|
persistent: p.persistent,
|
||||||
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
|
comment: p.comment,
|
||||||
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
|
disabled: p.disabled,
|
||||||
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
|
name: p.name,
|
||||||
if (p.comment) lines.push(` comment="${p.comment}" \\`)
|
clientAddress: p.clientAddress,
|
||||||
lines.push(``)
|
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 }: {
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
|
return {
|
||||||
}) {
|
id: String(s.id),
|
||||||
const [copied, setCopied] = useState(false)
|
name: s.name || s.host,
|
||||||
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
|
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() {
|
function parseEndpoint(endpoint: string): { address?: string; port?: number } {
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
const t = endpoint.trim()
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
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() {
|
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 [search, setSearch] = useState("")
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return allIfaces
|
if (!search) return displayIfaces
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return allIfaces.filter((i) =>
|
return displayIfaces.filter(
|
||||||
i.name.includes(q) ||
|
(i) =>
|
||||||
i.serverName.toLowerCase().includes(q) ||
|
i.name.toLowerCase().includes(q) ||
|
||||||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").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 totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||||
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
const onlinePeers = displayIfaces.reduce(
|
||||||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
(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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -160,8 +395,24 @@ export default function WireGuardPage() {
|
|||||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button size="sm">
|
{isLive && (
|
||||||
<PlusIcon className="size-4" />Новый интерфейс
|
<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>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -169,14 +420,12 @@ export default function WireGuardPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* KPI */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<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: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
||||||
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
|
{ 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: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
||||||
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Frame key={s.label} className="h-full">
|
<Frame key={s.label} className="h-full">
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||||
@@ -192,19 +441,20 @@ export default function WireGuardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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" />
|
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<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">
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
|
{isLive
|
||||||
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
|
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
|
||||||
|
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + table */}
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
@@ -214,63 +464,101 @@ export default function WireGuardPage() {
|
|||||||
/>
|
/>
|
||||||
<WireguardDataGrid
|
<WireguardDataGrid
|
||||||
interfaces={filtered}
|
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>
|
</DataPageCard>
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
|
||||||
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: "Создать интерфейс",
|
title: "Создать интерфейс",
|
||||||
lines: [
|
lines: [
|
||||||
"/interface wireguard add \\",
|
"/interface wireguard add \\",
|
||||||
" name=wg0 \\",
|
" name=wg0 \\",
|
||||||
" listen-port=13231 \\",
|
" listen-port=13231 \\",
|
||||||
" mtu=1420",
|
" mtu=1420",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Добавить пира",
|
title: "Добавить пира",
|
||||||
lines: [
|
lines: [
|
||||||
"/interface wireguard peers add \\",
|
"/interface wireguard peers add \\",
|
||||||
" interface=wg0 \\",
|
" interface=wg0 \\",
|
||||||
' public-key="<ключ>" \\',
|
' public-key="<ключ>" \\',
|
||||||
" allowed-address=10.0.0.2/32 \\",
|
" allowed-address=10.0.0.2/32 \\",
|
||||||
" endpoint-address=1.2.3.4 \\",
|
" endpoint-address=1.2.3.4 \\",
|
||||||
" persistent-keepalive=25",
|
" persistent-keepalive=25",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Назначить IP",
|
title: "Назначить IP",
|
||||||
lines: [
|
lines: [
|
||||||
"/ip address add \\",
|
"/ip address add \\",
|
||||||
" address=10.210.0.1/30 \\",
|
" address=10.210.0.1/30 \\",
|
||||||
" interface=wg0",
|
" interface=wg0",
|
||||||
"",
|
"",
|
||||||
"# Статус:",
|
"# Статус:",
|
||||||
"/interface wireguard print",
|
"/interface wireguard print",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
].map((b) => (
|
].map((b) => (
|
||||||
<div key={b.title}>
|
<div key={b.title}>
|
||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
{b.title}
|
||||||
{b.lines.join("\n")}
|
</p>
|
||||||
</pre>
|
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
||||||
</div>
|
{b.lines.join("\n")}
|
||||||
))}
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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}
|
open={!!exportIface}
|
||||||
iface={exportIface}
|
iface={exportIface}
|
||||||
onClose={() => setExportIface(null)}
|
onClose={() => {
|
||||||
|
setExportIface(null)
|
||||||
|
setLiveExport(null)
|
||||||
|
}}
|
||||||
|
liveContent={liveExport}
|
||||||
|
liveBusy={exportBusy}
|
||||||
|
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio",
|
"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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import backupsRoutes from "./routes/backups.js"
|
|||||||
import certificatesRoutes from "./routes/certificates.js"
|
import certificatesRoutes from "./routes/certificates.js"
|
||||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||||
import eventsRoutes from "./routes/events.js"
|
import eventsRoutes from "./routes/events.js"
|
||||||
|
import wireguardRoutes from "./routes/wireguard.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
|
||||||
export async function buildApp(opts?: {
|
export async function buildApp(opts?: {
|
||||||
@@ -103,6 +104,7 @@ export async function buildApp(opts?: {
|
|||||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||||
await app.register(eventsRoutes, { prefix: "/api" })
|
await app.register(eventsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
if (opts?.startScheduler !== false) {
|
if (opts?.startScheduler !== false) {
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
|
|||||||
@@ -21,5 +21,13 @@ assert.equal(
|
|||||||
permissionForRequest("GET", "/api/unknown-thing"),
|
permissionForRequest("GET", "/api/unknown-thing"),
|
||||||
"mm:dashboard:read",
|
"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")
|
console.log("permissions.test.ts: ok")
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ const RULES: Rule[] = [
|
|||||||
p.startsWith("/api/recursive") ||
|
p.startsWith("/api/recursive") ||
|
||||||
p.startsWith("/api/probes") ||
|
p.startsWith("/api/probes") ||
|
||||||
p.startsWith("/api/internet-path") ||
|
p.startsWith("/api/internet-path") ||
|
||||||
p.startsWith("/api/exec"),
|
p.startsWith("/api/exec") ||
|
||||||
|
p.startsWith("/api/wireguard"),
|
||||||
permission: "mm:network:read",
|
permission: "mm:network:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,7 +153,8 @@ const RULES: Rule[] = [
|
|||||||
p.startsWith("/api/recursive") ||
|
p.startsWith("/api/recursive") ||
|
||||||
p.startsWith("/api/probes") ||
|
p.startsWith("/api/probes") ||
|
||||||
p.startsWith("/api/internet-path") ||
|
p.startsWith("/api/internet-path") ||
|
||||||
p.startsWith("/api/exec"),
|
p.startsWith("/api/exec") ||
|
||||||
|
p.startsWith("/api/wireguard"),
|
||||||
permission: "mm:network:write",
|
permission: "mm:network:write",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 {
|
interface EvoCatalogRaw {
|
||||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||||
domains: {
|
domains: {
|
||||||
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
|||||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||||
const row = ensureEvobgpRow()
|
const row = ensureEvobgpRow()
|
||||||
const root = normalizeBaseUrl(row.baseUrl)
|
const root = normalizeBaseUrl(row.baseUrl)
|
||||||
const apiKey = row.apiKey.trim()
|
const apiKey = normalizeApiKey(row.apiKey)
|
||||||
if (!root || !apiKey) return null
|
if (!root || !apiKey) return null
|
||||||
return { root, apiKey }
|
return { root, apiKey }
|
||||||
}
|
}
|
||||||
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const row = ensureEvobgpRow()
|
const row = ensureEvobgpRow()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
baseUrl: row.baseUrl ?? "",
|
baseUrl: row.baseUrl ?? "",
|
||||||
enabled: row.enabled ?? false,
|
enabled: Boolean(row.enabled),
|
||||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
let nextEnabled = cur.enabled
|
let nextEnabled = cur.enabled
|
||||||
let nextKey = cur.apiKey
|
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.enabled !== undefined) nextEnabled = parsed.data.enabled
|
||||||
if (parsed.data.apiKey !== undefined) {
|
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)
|
db.update(evobgpSettings)
|
||||||
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const row = ensureEvobgpRow()
|
const row = ensureEvobgpRow()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
baseUrl: row.baseUrl ?? "",
|
baseUrl: row.baseUrl ?? "",
|
||||||
enabled: row.enabled ?? false,
|
enabled: Boolean(row.enabled),
|
||||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const keyRaw =
|
const keyRaw =
|
||||||
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
||||||
const root = normalizeBaseUrl(urlRaw.trim())
|
const root = normalizeBaseUrl(urlRaw.trim())
|
||||||
const token = keyRaw.trim()
|
const token = normalizeApiKey(keyRaw)
|
||||||
if (!root || !token) {
|
if (!root || !token) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||||
|
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import {
|
import {
|
||||||
filterRules,
|
filterRules,
|
||||||
@@ -18,6 +19,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
uptimeSpeedProbesTotal,
|
uptimeSpeedProbesTotal,
|
||||||
recursiveRoutesTotal,
|
recursiveRoutesTotal,
|
||||||
certificatesTotal,
|
certificatesTotal,
|
||||||
|
wireguardTotal,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
Promise.resolve(db.select().from(servers).all().length),
|
Promise.resolve(db.select().from(servers).all().length),
|
||||||
Promise.resolve(db.select().from(filterRules).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(uptimeSpeedProbes).all().length),
|
||||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||||
|
countWireGuardInterfaces().catch(() => 0),
|
||||||
])
|
])
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
@@ -35,6 +38,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||||
recursiveRoutes: recursiveRoutesTotal,
|
recursiveRoutes: recursiveRoutesTotal,
|
||||||
certificates: certificatesTotal,
|
certificates: certificatesTotal,
|
||||||
|
wireguard: wireguardTotal,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 records = await listDnsRecordsByName(token, zoneId, fqdn)
|
||||||
const existingA = records.find((record) => record.type === "A")
|
const existingA = records.find((record) => record.type === "A")
|
||||||
if (existingA) {
|
if (existingA) {
|
||||||
if (existingA.content === ip) return
|
if (existingA.content === ip) return "updated"
|
||||||
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
|
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
|||||||
proxied: false,
|
proxied: false,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
return
|
return "updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
|
||||||
if (records.some((record) => record.type === "CNAME")) {
|
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`, {
|
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,
|
proxied: false,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
return "created"
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncCertificateDomainRecords(
|
async function syncCertificateDomainRecords(
|
||||||
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
|
|||||||
domains: string[],
|
domains: string[],
|
||||||
serverIp: string,
|
serverIp: string,
|
||||||
defaultZoneId?: string,
|
defaultZoneId?: string,
|
||||||
): Promise<void> {
|
): Promise<{ skippedCname: string[] }> {
|
||||||
|
const skippedCname: string[] = []
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
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) {
|
async function sleep(ms: number) {
|
||||||
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
|
|||||||
const finalized = await client.finalizeOrder(order, csr)
|
const finalized = await client.finalizeOrder(order, csr)
|
||||||
const certPem = await client.getCertificate(finalized)
|
const certPem = await client.getCertificate(finalized)
|
||||||
|
|
||||||
|
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
|
||||||
const clientRos = MikrotikClient.fromServer(params.server)
|
const clientRos = MikrotikClient.fromServer(params.server)
|
||||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
try {
|
||||||
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
|
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 trustStores = params.trustStore.filter(Boolean)
|
||||||
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
|
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")
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
@@ -38,6 +38,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
formatSidebarBadgeCount,
|
formatSidebarBadgeCount,
|
||||||
mockSidebarBadgesByUrl,
|
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>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const evo = useEvoBGP()
|
const evo = useEvoBGP()
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||||
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (mode !== "live") {
|
if (!prefsHydrated || mode !== "live") {
|
||||||
setLiveCounts(null)
|
if (mode !== "live") setLiveCounts(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const base = backendUrl.replace(/\/$/, "")
|
const [cJson, gJson] = await Promise.all([
|
||||||
const [cRes, gRes] = await Promise.all([
|
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
||||||
fetch(`${base}/api/sidebar-counts`),
|
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
|
||||||
fetch(`${base}/api/filters/gre-tunnels`),
|
() => ({ tunnels: [] as unknown[] }),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (!cRes.ok) {
|
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
|
||||||
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 })
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setLiveCounts(null)
|
if (!cancelled) setLiveCounts(null)
|
||||||
}
|
}
|
||||||
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
window.clearInterval(id)
|
window.clearInterval(id)
|
||||||
}
|
}
|
||||||
}, [mode, backendUrl])
|
}, [mode, backendUrl, prefsHydrated])
|
||||||
|
|
||||||
const navGroups = React.useMemo((): NavGroup[] => {
|
const navGroups = React.useMemo((): NavGroup[] => {
|
||||||
function badgeFor(url: string): string | undefined {
|
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 === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
||||||
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
||||||
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CodeXmlIcon,
|
CodeXmlIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
@@ -49,9 +48,22 @@ export interface WgIfaceWithServer extends WireGuardInterface {
|
|||||||
interface WireguardDataGridProps {
|
interface WireguardDataGridProps {
|
||||||
interfaces: WgIfaceWithServer[]
|
interfaces: WgIfaceWithServer[]
|
||||||
onExport: (iface: WgIfaceWithServer) => void
|
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>[]>(
|
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>
|
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
<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}
|
{iface.serverName}
|
||||||
</div>
|
</div>
|
||||||
<p className="sr-only">
|
<p className="sr-only">
|
||||||
@@ -97,7 +109,11 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
|||||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
expandedContent: (row: WgIfaceWithServer) => (
|
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">
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
<DropdownMenuItem onClick={() => onExport(iface)}>
|
<DropdownMenuItem onClick={() => onExport(iface)}>
|
||||||
<CodeXmlIcon className="size-4" />
|
<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>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +258,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onExport],
|
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import type { WireGuardPeer } from "@/lib/data"
|
import type { WireGuardPeer } from "@/lib/data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
|
CodeXmlIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
function fmtBytes(n: number | undefined): string {
|
function fmtBytes(n: number | undefined): string {
|
||||||
@@ -21,7 +24,19 @@ function truncKey(key: string): string {
|
|||||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
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) {
|
if (peers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
<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 (
|
return (
|
||||||
<div className="border-t border-border/50">
|
<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>Public Key</span>
|
||||||
<span>Allowed IPs</span>
|
<span>Allowed IPs</span>
|
||||||
<span>Последнее рукопожатие</span>
|
<span>Последнее рукопожатие</span>
|
||||||
<span>RX / TX</span>
|
<span>RX / TX</span>
|
||||||
<span>Endpoint</span>
|
<span>Endpoint</span>
|
||||||
|
<span className="sr-only">Действия</span>
|
||||||
</div>
|
</div>
|
||||||
{peers.map((peer) => (
|
{peers.map((peer, index) => {
|
||||||
<div
|
const id = peerKey(peer, index)
|
||||||
key={peer.publicKey}
|
return (
|
||||||
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
|
||||||
>
|
key={id}
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
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"
|
||||||
<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 ?? "нет рукопожатия"}
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
</span>
|
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||||
<span className="flex items-center gap-0.5">
|
{truncKey(peer.publicKey)}
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
</span>
|
||||||
{fmtBytes(peer.transferRx)}
|
</div>
|
||||||
</span>
|
<div className="font-mono text-muted-foreground truncate">
|
||||||
<span className="flex items-center gap-0.5">
|
{peer.allowedIps.join(", ")}
|
||||||
<ArrowUpIcon className="size-3 text-blue-400" />
|
</div>
|
||||||
{fmtBytes(peer.transferTx)}
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-[11px] whitespace-nowrap",
|
||||||
|
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { filters, pingProbes, servers } from "@/lib/data"
|
import { filters, pingProbes, servers } from "@/lib/data"
|
||||||
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
||||||
|
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
type MonitorMetric = {
|
type MonitorMetric = {
|
||||||
id: string
|
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 */
|
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
|
||||||
export function SystemMonitorPopover() {
|
export function SystemMonitorPopover() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||||
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!prefsHydrated) return
|
||||||
if (mode !== "live") {
|
if (mode !== "live") {
|
||||||
setHealthOk(true)
|
setHealthOk(true)
|
||||||
setCounts({
|
setCounts({
|
||||||
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
|
|||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const base = backendUrl.replace(/\/$/, "")
|
|
||||||
try {
|
try {
|
||||||
const [hRes, cRes] = await Promise.all([
|
const [hRes, counts] = await Promise.all([
|
||||||
fetch(`${base}/health`),
|
fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
|
||||||
fetch(`${base}/api/sidebar-counts`),
|
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (hRes.ok) {
|
if (hRes.ok) {
|
||||||
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
|
|||||||
} else {
|
} else {
|
||||||
setHealthOk(false)
|
setHealthOk(false)
|
||||||
}
|
}
|
||||||
if (cRes.ok) {
|
setCounts(counts)
|
||||||
setCounts((await cRes.json()) as SidebarCountsDto)
|
|
||||||
} else {
|
|
||||||
setCounts(null)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setHealthOk(false)
|
setHealthOk(false)
|
||||||
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
window.clearInterval(id)
|
window.clearInterval(id)
|
||||||
}
|
}
|
||||||
}, [mode, backendUrl])
|
}, [mode, backendUrl, prefsHydrated])
|
||||||
|
|
||||||
const serversCount = counts?.servers ?? 0
|
const serversCount = counts?.servers ?? 0
|
||||||
const filtersCount = counts?.filterRules ?? 0
|
const filtersCount = counts?.filterRules ?? 0
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
|
|||||||
return cfg.kind === "same-origin" || cfg.kind === "fixed"
|
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 {
|
export function resolveStoredBackendUrl(stored: string | null): string {
|
||||||
const cfg = configuredBackendUrl()
|
const cfg = configuredBackendUrl()
|
||||||
if (cfg.kind === "fixed") return cfg.url
|
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
|
return window.location.origin
|
||||||
}
|
}
|
||||||
const trimmed = stored?.trim().replace(/\/$/, "")
|
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
|
return trimmed || LOCAL_DEFAULT_BACKEND_URL
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -9,6 +9,7 @@ import {
|
|||||||
LOCAL_DEFAULT_BACKEND_URL,
|
LOCAL_DEFAULT_BACKEND_URL,
|
||||||
resolveStoredBackendUrl,
|
resolveStoredBackendUrl,
|
||||||
} from "@/lib/backend-url"
|
} from "@/lib/backend-url"
|
||||||
|
import { resolveApiUrl } from "@/shared/api/http-client"
|
||||||
|
|
||||||
// ── types ─────────────────────────────────────────────────────────────────────
|
// ── types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
|
|||||||
return defaultDataSourceMode()
|
return defaultDataSourceMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredBackendUrl(): string {
|
function initialBackendUrl(): string {
|
||||||
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
|
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))
|
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
|
|||||||
|
|
||||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
|
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 [prefsHydrated, setPrefsHydrated] = useState(false)
|
||||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||||
const backendUrlLocked = isBackendUrlLocked()
|
const backendUrlLocked = isBackendUrlLocked()
|
||||||
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedMode = readStoredMode()
|
const storedMode = readStoredMode()
|
||||||
let url = readStoredBackendUrl()
|
const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
|
||||||
if (configuredBackendUrl().kind === "same-origin") {
|
|
||||||
url = window.location.origin
|
|
||||||
}
|
|
||||||
setModeState(storedMode)
|
setModeState(storedMode)
|
||||||
setBackendUrlState(url)
|
setBackendUrlState(url)
|
||||||
setPrefsHydrated(true)
|
setPrefsHydrated(true)
|
||||||
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
|||||||
}, [backendUrlLocked])
|
}, [backendUrlLocked])
|
||||||
|
|
||||||
const checkBackend = useCallback(async () => {
|
const checkBackend = useCallback(async () => {
|
||||||
const healthUrl =
|
const healthUrl = resolveApiUrl(backendUrl, "/health")
|
||||||
configuredBackendUrl().kind === "same-origin"
|
|
||||||
? "/health"
|
|
||||||
: `${normalizeBackendUrl(backendUrl)}/health`
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
|
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
|
||||||
setBackendStatus(res.ok)
|
setBackendStatus(res.ok)
|
||||||
|
|||||||
+12
@@ -14,21 +14,33 @@ export interface WanUplink {
|
|||||||
// ─── WireGuard ───────────────────────────────────────────────────────────────
|
// ─── WireGuard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface WireGuardPeer {
|
export interface WireGuardPeer {
|
||||||
|
id?: string
|
||||||
|
rosId?: string
|
||||||
publicKey: string
|
publicKey: string
|
||||||
allowedIps: string[]
|
allowedIps: string[]
|
||||||
endpoint?: string // "1.2.3.4:13231"
|
endpoint?: string // "1.2.3.4:13231"
|
||||||
latestHandshake?: string // "2 минуты назад"
|
latestHandshake?: string // "2 минуты назад"
|
||||||
transferRx?: number // bytes
|
transferRx?: number // bytes
|
||||||
transferTx?: number // bytes
|
transferTx?: number // bytes
|
||||||
|
persistentKeepalive?: number
|
||||||
persistent?: boolean
|
persistent?: boolean
|
||||||
comment?: string
|
comment?: string
|
||||||
|
disabled?: boolean
|
||||||
|
name?: string
|
||||||
|
clientAddress?: string
|
||||||
|
clientDns?: string
|
||||||
|
clientEndpoint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WireGuardInterface {
|
export interface WireGuardInterface {
|
||||||
id: string
|
id: string
|
||||||
|
rosId?: string
|
||||||
name: string // e.g. "wg-msk-spb"
|
name: string // e.g. "wg-msk-spb"
|
||||||
listenPort: number // default 13231
|
listenPort: number // default 13231
|
||||||
mtu: number // 1420 default in ROS 7.x
|
mtu: number // 1420 default in ROS 7.x
|
||||||
|
publicKey?: string
|
||||||
|
privateKey?: string
|
||||||
|
address?: string
|
||||||
peers: WireGuardPeer[]
|
peers: WireGuardPeer[]
|
||||||
comment: string
|
comment: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|||||||
+56
-57
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import type { Domain, IpRange, Asn } from "@/lib/data"
|
import type { Domain, IpRange, Asn } from "@/lib/data"
|
||||||
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
export interface EvoBgpCommunityRow {
|
export interface EvoBgpCommunityRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
|
|||||||
|
|
||||||
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
|
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 }) {
|
export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||||
const [baseUrl, setBaseUrlState] = useState("")
|
const [baseUrl, setBaseUrlState] = useState("")
|
||||||
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, {
|
const data = await requestJson<EvoBgpCatalogSnapshot>(
|
||||||
method: "POST",
|
backendUrl,
|
||||||
})
|
"/api/evobgp/catalog",
|
||||||
const text = await res.text()
|
{ method: "POST" },
|
||||||
if (!res.ok) {
|
)
|
||||||
let msg = res.statusText
|
setSnapshot(data)
|
||||||
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)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSnapshot(null)
|
setSnapshot(null)
|
||||||
setError(e instanceof Error ? e.message : "Ошибка загрузки")
|
setError(errorMessage(e, "Ошибка загрузки"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`)
|
const data = await requestJson<EvoBgpSettingsDto>(
|
||||||
if (!res.ok) throw new Error(await res.text())
|
backendUrl,
|
||||||
const data = (await res.json()) as EvoBgpSettingsDto
|
"/api/evobgp/settings",
|
||||||
|
)
|
||||||
setBaseUrlState(data.baseUrl ?? "")
|
setBaseUrlState(data.baseUrl ?? "")
|
||||||
setEnabledState(data.enabled ?? false)
|
setEnabledState(Boolean(data.enabled))
|
||||||
setSecretConfigured(data.secretConfigured ?? false)
|
setSecretConfigured(Boolean(data.secretConfigured))
|
||||||
setSettingsLoaded(true)
|
setSettingsLoaded(true)
|
||||||
await pullCatalog(data.enabled ?? false)
|
setError(null)
|
||||||
} catch {
|
await pullCatalog(Boolean(data.enabled))
|
||||||
|
} catch (e) {
|
||||||
setSettingsLoaded(true)
|
setSettingsLoaded(true)
|
||||||
|
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
|
||||||
}
|
}
|
||||||
}, [mode, backendStatus, backendUrl, pullCatalog])
|
}, [mode, backendStatus, backendUrl, pullCatalog])
|
||||||
|
|
||||||
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const saveSettings = useCallback(
|
const saveSettings = useCallback(
|
||||||
async (patch: EvoBgpSavePayload) => {
|
async (patch: EvoBgpSavePayload) => {
|
||||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, {
|
const data = await requestJson<EvoBgpSettingsDto>(
|
||||||
method: "PUT",
|
backendUrl,
|
||||||
headers: { "Content-Type": "application/json" },
|
"/api/evobgp/settings",
|
||||||
body: JSON.stringify(patch),
|
{
|
||||||
})
|
method: "PUT",
|
||||||
const text = await res.text()
|
body: JSON.stringify(patch),
|
||||||
if (!res.ok) {
|
},
|
||||||
let msg = res.statusText
|
)
|
||||||
try {
|
const nextEnabled = Boolean(data.enabled)
|
||||||
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
|
|
||||||
setBaseUrlState(data.baseUrl ?? "")
|
setBaseUrlState(data.baseUrl ?? "")
|
||||||
setEnabledState(data.enabled ?? false)
|
setEnabledState(nextEnabled)
|
||||||
setSecretConfigured(data.secretConfigured ?? false)
|
setSecretConfigured(Boolean(data.secretConfigured))
|
||||||
await pullCatalog(data.enabled ?? false)
|
setError(null)
|
||||||
|
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
|
||||||
|
try {
|
||||||
|
await pullCatalog(nextEnabled)
|
||||||
|
} catch {
|
||||||
|
/* pullCatalog already sets error state */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[backendUrl, pullCatalog],
|
[backendUrl, pullCatalog],
|
||||||
)
|
)
|
||||||
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await pullCatalog(enabled)
|
await pullCatalog(enabled)
|
||||||
}, [enabled, pullCatalog])
|
}, [enabled, pullCatalog])
|
||||||
|
|
||||||
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => {
|
const testConnection = useCallback(
|
||||||
try {
|
async (draft?: EvoBgpTestDraft) => {
|
||||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, {
|
try {
|
||||||
method: "POST",
|
await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify(draft ?? {}),
|
body: JSON.stringify(draft ?? {}),
|
||||||
})
|
})
|
||||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
return { ok: true, message: "Соединение с EvoBGP установлено" }
|
||||||
if (!res.ok) throw new Error(data.error ?? res.statusText)
|
} catch (e) {
|
||||||
return { ok: true, message: "Соединение с EvoBGP установлено" }
|
return { ok: false, message: errorMessage(e, "Ошибка") }
|
||||||
} catch (e) {
|
}
|
||||||
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" }
|
},
|
||||||
}
|
[backendUrl],
|
||||||
}, [backendUrl])
|
)
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -52,4 +52,6 @@ export interface SidebarCountsDto {
|
|||||||
uptimeSpeedProbes: number
|
uptimeSpeedProbes: number
|
||||||
monitoringItems: number
|
monitoringItems: number
|
||||||
recursiveRoutes: number
|
recursiveRoutes: number
|
||||||
|
certificates?: number
|
||||||
|
wireguard?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@
|
|||||||
"./backups": {
|
"./backups": {
|
||||||
"types": "./dist/backups.d.ts",
|
"types": "./dist/backups.d.ts",
|
||||||
"default": "./dist/backups.js"
|
"default": "./dist/backups.js"
|
||||||
|
},
|
||||||
|
"./wireguard": {
|
||||||
|
"types": "./dist/wireguard.d.ts",
|
||||||
|
"default": "./dist/wireguard.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from "./alerts.js"
|
|||||||
export * from "./events.js"
|
export * from "./events.js"
|
||||||
export * from "./certificates.js"
|
export * from "./certificates.js"
|
||||||
export * from "./backups.js"
|
export * from "./backups.js"
|
||||||
|
export * from "./wireguard.js"
|
||||||
|
|||||||
@@ -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
@@ -21,38 +21,72 @@ function trimBaseUrl(baseUrl: string): string {
|
|||||||
return baseUrl.replace(/\/$/, "")
|
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") {
|
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
|
||||||
return path
|
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
|
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>(
|
export async function requestJson<T>(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const hasBody = init?.body != null
|
const hasBody = init?.body != null
|
||||||
const headers = new Headers(init?.headers)
|
const headers = withAuthHeaders(init?.headers)
|
||||||
if (hasBody && !headers.has("Content-Type")) {
|
if (hasBody && !headers.has("Content-Type")) {
|
||||||
headers.set("Content-Type", "application/json")
|
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,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) {
|
if (res.status === 401) handleUnauthorized()
|
||||||
const ok = redirectToPortalLogin()
|
|
||||||
if (!ok) redirectToPortalLoginInteractive()
|
|
||||||
throw new ApiClientError("Unauthorized", 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
@@ -70,3 +104,21 @@ export async function requestJson<T>(
|
|||||||
|
|
||||||
return payload as 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { ApiClientError } from "@/shared/api/http-client"
|
import { ApiClientError, requestBlob } from "@/shared/api/http-client"
|
||||||
import { configuredBackendUrl } from "@/lib/backend-url"
|
|
||||||
|
|
||||||
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
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 {
|
function parseFilename(contentDisposition: string | null, fallback: string): string {
|
||||||
if (!contentDisposition) return fallback
|
if (!contentDisposition) return fallback
|
||||||
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
||||||
@@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
|
|||||||
export async function downloadSystemDatabaseBackup(
|
export async function downloadSystemDatabaseBackup(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
): Promise<{ blob: Blob; filename: string }> {
|
): Promise<{ blob: Blob; filename: string }> {
|
||||||
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup"))
|
const res = await requestBlob(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 blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
|
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
|
||||||
return { blob, filename }
|
return { blob, filename }
|
||||||
@@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File):
|
|||||||
413,
|
413,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
|
await requestBlob(baseUrl, "/api/system/database/restore", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
body: file,
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user