493 lines
21 KiB
TypeScript
493 lines
21 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { servers } from "@/lib/data"
|
||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||
import { Flag } from "@/components/flag"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { cn } from "@/lib/utils"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||
SheetDescription, SheetFooter, SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
import {
|
||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||
DropdownMenuItem, DropdownMenuSeparator,
|
||
} from "@/components/ui/dropdown-menu"
|
||
import {
|
||
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
|
||
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
|
||
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
|
||
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
|
||
} from "lucide-react"
|
||
|
||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
||
|
||
interface WgIfaceWithServer extends WireGuardInterface {
|
||
serverId: string
|
||
serverName: string
|
||
serverCountry: string
|
||
}
|
||
|
||
function collectInterfaces(): WgIfaceWithServer[] {
|
||
const result: WgIfaceWithServer[] = []
|
||
for (const srv of servers) {
|
||
for (const wg of srv.wireGuardIfaces ?? []) {
|
||
result.push({
|
||
...wg,
|
||
serverId: srv.id,
|
||
serverName: srv.name,
|
||
serverCountry: srv.country,
|
||
})
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function fmtBytes(n: number | undefined): string {
|
||
if (!n) return "—"
|
||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||
return `${n} Б`
|
||
}
|
||
|
||
function truncKey(key: string): string {
|
||
if (key.length <= 20) return key
|
||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||
}
|
||
|
||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||
|
||
function generateWgRsc(iface: WgIfaceWithServer): string {
|
||
const lines: string[] = []
|
||
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
|
||
lines.push(`# RouterOS 7.x`)
|
||
lines.push(``)
|
||
lines.push(`/interface wireguard add \\`)
|
||
lines.push(` name=${iface.name} \\`)
|
||
lines.push(` listen-port=${iface.listenPort} \\`)
|
||
lines.push(` mtu=${iface.mtu} \\`)
|
||
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
|
||
if (!iface.enabled) lines.push(` disabled=yes \\`)
|
||
lines.push(``)
|
||
for (const p of iface.peers) {
|
||
lines.push(`/interface wireguard peers add \\`)
|
||
lines.push(` interface=${iface.name} \\`)
|
||
lines.push(` public-key="${p.publicKey}" \\`)
|
||
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
|
||
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
|
||
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
|
||
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
|
||
if (p.comment) lines.push(` comment="${p.comment}" \\`)
|
||
lines.push(``)
|
||
}
|
||
return lines.join("\n")
|
||
}
|
||
|
||
// ─── Peer row ─────────────────────────────────────────────────────────────────
|
||
|
||
function PeerRow({ peer }: { peer: WireGuardPeer }) {
|
||
return (
|
||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
|
||
{/* public key */}
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||
{truncKey(peer.publicKey)}
|
||
</span>
|
||
</div>
|
||
{/* allowed IPs */}
|
||
<div className="font-mono text-muted-foreground truncate">
|
||
{peer.allowedIps.join(", ")}
|
||
</div>
|
||
{/* handshake */}
|
||
<span className={cn(
|
||
"font-mono text-[11px] whitespace-nowrap",
|
||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||
)}>
|
||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||
</span>
|
||
{/* rx / tx */}
|
||
<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>
|
||
{/* endpoint */}
|
||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Interface card ───────────────────────────────────────────────────────────
|
||
|
||
function IfaceRow({
|
||
iface,
|
||
expanded,
|
||
onToggleExpand,
|
||
onExport,
|
||
}: {
|
||
iface: WgIfaceWithServer
|
||
expanded: boolean
|
||
onToggleExpand: () => void
|
||
onExport: () => void
|
||
}) {
|
||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||
|
||
return (
|
||
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
|
||
<div
|
||
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
|
||
onClick={onToggleExpand}
|
||
>
|
||
{/* expand */}
|
||
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
|
||
{expanded
|
||
? <ChevronDownIcon className="size-3.5" />
|
||
: <ChevronRightIcon className="size-3.5" />}
|
||
</button>
|
||
|
||
{/* name + server */}
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn(
|
||
"size-2 rounded-full shrink-0",
|
||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||
)} />
|
||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||
<Flag code={iface.serverCountry} size={12} />
|
||
{iface.serverName}
|
||
</div>
|
||
</div>
|
||
|
||
{/* port */}
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-muted-foreground">Порт</p>
|
||
<p className="font-mono text-sm">{iface.listenPort}</p>
|
||
</div>
|
||
|
||
{/* MTU */}
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-muted-foreground">MTU</p>
|
||
<p className="font-mono text-sm">{iface.mtu}</p>
|
||
</div>
|
||
|
||
{/* peers */}
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-muted-foreground">Пиров</p>
|
||
<p className="font-mono text-sm">
|
||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* status badge */}
|
||
<span className={cn(
|
||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||
iface.status === "up"
|
||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||
)}>
|
||
{iface.status === "up" ? "UP" : "DOWN"}
|
||
</span>
|
||
|
||
{/* menu */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
|
||
<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>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
{/* expanded peers */}
|
||
{expanded && iface.peers.length > 0 && (
|
||
<div>
|
||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
|
||
<span>Public Key</span>
|
||
<span>Allowed IPs</span>
|
||
<span>Последнее рукопожатие</span>
|
||
<span>RX / TX</span>
|
||
<span>Endpoint</span>
|
||
</div>
|
||
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
|
||
</div>
|
||
)}
|
||
{expanded && iface.peers.length === 0 && (
|
||
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||
Нет пиров
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||
|
||
function ExportSheet({ open, iface, onClose }: {
|
||
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
|
||
}) {
|
||
const [copied, setCopied] = useState(false)
|
||
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
|
||
|
||
function handleCopy() {
|
||
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>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||
{copied
|
||
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
|
||
: <><CopyIcon className="size-3.5" />Копировать</>}
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||
{code.split("\n").map((line, i) => {
|
||
const isComment = line.startsWith("#")
|
||
const isCmd = line.trimStart().startsWith("/interface")
|
||
const isParam = /^\s+[a-z]/.test(line)
|
||
return (
|
||
<span key={i} className={
|
||
isComment ? "text-muted-foreground"
|
||
: isCmd ? "text-sky-400"
|
||
: isParam ? "text-violet-300"
|
||
: "text-foreground"
|
||
}>
|
||
{line}{"\n"}
|
||
</span>
|
||
)
|
||
})}
|
||
</pre>
|
||
</div>
|
||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||
<Button className="flex-1" onClick={handleCopy}>
|
||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
export default function WireGuardPage() {
|
||
const allIfaces = useMemo(() => collectInterfaces(), [])
|
||
|
||
const [search, setSearch] = useState("")
|
||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!search) return allIfaces
|
||
const q = search.toLowerCase()
|
||
return allIfaces.filter((i) =>
|
||
i.name.includes(q) ||
|
||
i.serverName.toLowerCase().includes(q) ||
|
||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q))
|
||
)
|
||
}, [allIfaces, search])
|
||
|
||
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
||
|
||
function toggleExpand(id: string) {
|
||
setExpandedIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) next.delete(id); else next.add(id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||
actions={
|
||
<>
|
||
<Button size="sm">
|
||
<PlusIcon className="size-4" />Новый интерфейс
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{/* KPI */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
||
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
|
||
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
||
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
||
].map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||
</div>
|
||
<div className="mt-0.5">{s.icon}</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Info banner */}
|
||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard — рекомендуемый туннельный протокол в RouterOS 7.x</p>
|
||
<p className="text-muted-foreground text-xs mt-0.5">
|
||
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
|
||
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search + table */}
|
||
<Card>
|
||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
|
||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<input
|
||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||
placeholder="Поиск по имени, серверу, IP…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
|
||
</div>
|
||
|
||
{/* table header */}
|
||
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||
<span />
|
||
<span>Интерфейс / Сервер</span>
|
||
<span>Порт</span>
|
||
<span>MTU</span>
|
||
<span>Пиры</span>
|
||
<span>Статус</span>
|
||
<span />
|
||
</div>
|
||
|
||
{filtered.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
||
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
|
||
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
|
||
</div>
|
||
) : (
|
||
filtered.map((iface) => (
|
||
<IfaceRow
|
||
key={iface.id}
|
||
iface={iface}
|
||
expanded={expandedIds.has(iface.id)}
|
||
onToggleExpand={() => toggleExpand(iface.id)}
|
||
onExport={() => setExportIface(iface)}
|
||
/>
|
||
))
|
||
)}
|
||
</Card>
|
||
|
||
{/* RouterOS reference */}
|
||
<Card>
|
||
<CardContent className="px-5 py-4">
|
||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||
RouterOS 7 · /interface wireguard — быстрые команды
|
||
</p>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||
{[
|
||
{
|
||
title: "Создать интерфейс",
|
||
lines: [
|
||
"/interface wireguard add \\",
|
||
" name=wg0 \\",
|
||
" listen-port=13231 \\",
|
||
" mtu=1420",
|
||
],
|
||
},
|
||
{
|
||
title: "Добавить пира",
|
||
lines: [
|
||
"/interface wireguard peers add \\",
|
||
" interface=wg0 \\",
|
||
' public-key="<ключ>" \\',
|
||
" allowed-address=10.0.0.2/32 \\",
|
||
" endpoint-address=1.2.3.4 \\",
|
||
" persistent-keepalive=25",
|
||
],
|
||
},
|
||
{
|
||
title: "Назначить IP",
|
||
lines: [
|
||
"/ip address add \\",
|
||
" address=10.210.0.1/30 \\",
|
||
" interface=wg0",
|
||
"",
|
||
"# Статус:",
|
||
"/interface wireguard print",
|
||
],
|
||
},
|
||
].map((b) => (
|
||
<div key={b.title}>
|
||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
||
{b.lines.join("\n")}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<ExportSheet
|
||
open={!!exportIface}
|
||
iface={exportIface}
|
||
onClose={() => setExportIface(null)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|