feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s

- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
Denozordec
2026-09-12 20:20:56 +07:00
parent 564aae21f0
commit 7755d77340
35 changed files with 4154 additions and 13 deletions
+4
View File
@@ -37,6 +37,7 @@ import {
BoxIcon,
BadgeCheckIcon,
UsersIcon,
LockIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
@@ -78,6 +79,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon /> },
{ title: "IPsec / IKEv2", url: "/ipsec", icon: <LockIcon /> },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon /> },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon /> },
@@ -110,6 +112,7 @@ type LiveSidebarCounts = SidebarCountsDto & {
greTunnels?: number
certificates?: number
wireguard?: number
ipsec?: number
users?: number
bgpSessions?: number
vxlan?: number
@@ -179,6 +182,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/ipsec") return formatSidebarBadgeCount(liveCounts.ipsec ?? 0)
if (url === "/bgp") return formatSidebarBadgeCount(liveCounts.bgpSessions ?? 0)
if (url === "/vxlan") return formatSidebarBadgeCount(liveCounts.vxlan ?? 0)
if (url === "/containers") return formatSidebarBadgeCount(liveCounts.containers ?? 0)
+2 -1
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, LockIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -42,6 +42,7 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
{ id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: <ShieldCheckIcon />, keywords: ["vpn","tunnel","туннель","wg"] },
{ id: "ipsec", title: "IPsec / IKEv2", group: "Управление", url: "/ipsec", icon: <LockIcon />, keywords: ["vpn","ikev2","ipsec","сертификат","p12","клиент"] },
{ id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: <CableIcon />, keywords: ["gre","ipsec","tunnel","туннель"] },
{ id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: <NetworkIcon />, keywords: ["overlay","vni","vtep","l2"] },
{ id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: <BoxIcon />, keywords: ["docker","container","образ","image"] },
@@ -12,12 +12,13 @@ import {
type AppUser,
type InterfaceType,
} from "@/lib/users"
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
import { CableIcon, KeyRoundIcon, LockIcon, NetworkIcon, ShieldIcon } from "lucide-react"
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
ether: "outline",
gre: "info-light",
wg: "success-light",
ipsec: "info-light",
other: "secondary",
}
@@ -25,6 +26,7 @@ const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: stri
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
ipsec: { icon: LockIcon, className: "text-info" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
+181
View File
@@ -0,0 +1,181 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import type { IpsecCertBundle } from "@mmapp/contracts/ipsec"
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 { toast } from "sonner"
import { DownloadIcon, CopyIcon, RefreshCwIcon } from "lucide-react"
function downloadBlob(filename: string, blob: Blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function downloadText(filename: string, content: string) {
downloadBlob(filename, new Blob([content], { type: "text/plain;charset=utf-8" }))
}
function downloadB64(filename: string, b64: string, mime: string) {
const bin = atob(b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
downloadBlob(filename, new Blob([bytes], { type: mime }))
}
function randomPassphrase(): string {
const bytes = new Uint8Array(9)
crypto.getRandomValues(bytes)
let s = ""
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
return s
}
function IpsecCertSheet({
open,
onOpenChange,
bundle,
busy,
onReexport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
bundle: IpsecCertBundle | null
busy?: boolean
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
onReexport?: (passphrase: string) => void | Promise<void>
}) {
const [passphrase, setPassphrase] = useState("")
useEffect(() => {
if (!open) return
const initial = bundle?.passphrase ?? ""
queueMicrotask(() => setPassphrase(initial))
}, [open, bundle])
const canDownload = useMemo(() => Boolean(bundle && passphrase.trim().length >= 4), [bundle, passphrase])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<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>Сертификат клиента {bundle ? `«${bundle.user}»` : ""}</SheetTitle>
<SheetDescription>
.p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{!bundle ? (
<p className="text-sm text-muted-foreground">
Бандл сертификата пуст перезапустите экспорт с новой парольной фразой.
</p>
) : (
<>
<div className="flex flex-col gap-4">
<SectionTitle>Пароль архива .p12</SectionTitle>
<FormField label="Passphrase" required hint="Нужна при импорте .p12 на устройстве">
<div className="flex gap-2">
<Input
className="font-mono"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
/>
<Button
type="button"
size="icon"
variant="outline"
title="Сгенерировать и перекачать"
disabled={busy}
onClick={() => {
const next = randomPassphrase()
setPassphrase(next)
if (onReexport) void onReexport(next)
}}
>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
</Button>
</div>
</FormField>
{bundle.serverEndpoint ? (
<p className="text-xs text-muted-foreground">
Сервер: <span className="font-mono">{bundle.serverEndpoint}</span>
</p>
) : null}
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Файлы</SectionTitle>
<Button
variant="outline"
className="justify-start"
disabled={!canDownload}
onClick={() => {
if (!bundle) return
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
toast.success(`Скачан ${bundle.filename}`)
}}
>
<DownloadIcon className="size-4" />
{bundle.filename} (.p12, сертификат + ключ)
</Button>
{bundle.sswanContent && bundle.sswanFilename ? (
<Button
variant="outline"
className="justify-start"
disabled={!canDownload}
onClick={() => {
if (!bundle.sswanContent || !bundle.sswanFilename) return
downloadText(bundle.sswanFilename, bundle.sswanContent)
toast.success(`Скачан ${bundle.sswanFilename}`)
}}
>
<DownloadIcon className="size-4" />
{bundle.sswanFilename} (strongSwan)
</Button>
) : null}
{bundle.instructions ? (
<Button
variant="ghost"
className="justify-start text-muted-foreground"
onClick={() => {
if (!bundle.instructions) return
void navigator.clipboard?.writeText(bundle.instructions)
toast.success("Инструкция скопирована")
}}
>
<CopyIcon className="size-4" />
Скопировать инструкцию по подключению
</Button>
) : null}
</div>
{bundle.instructions ? (
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap">
{bundle.instructions}
</pre>
) : null}
</>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0">
<SheetClose render={<Button variant="outline" className="w-full" />}>
Закрыть
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecCertSheet, downloadText, downloadB64 }
+162
View File
@@ -0,0 +1,162 @@
"use client"
import { useEffect, 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 { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { InfoIcon } from "lucide-react"
export type IpsecInitFormState = {
serverId: string
serverEndpoint: string
poolCidr: string
dns: string
createNatRule: boolean
}
export const defaultIpsecInitForm = (): IpsecInitFormState => ({
serverId: "",
serverEndpoint: "",
poolCidr: "10.77.0.0/24",
dns: "",
createNatRule: true,
})
type ServerOption = { id: string; name: string; host: string }
function IpsecInitSheet({
open,
onOpenChange,
servers,
busy,
defaultServerId,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onSubmit: (form: IpsecInitFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecInitFormState>(defaultIpsecInitForm)
const set = <K extends keyof IpsecInitFormState>(k: K, v: IpsecInitFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
const server = servers.find((s) => s.id === (defaultServerId ?? ""))
queueMicrotask(() => setForm({
...defaultIpsecInitForm(),
serverId: defaultServerId ?? "",
serverEndpoint: server?.host ?? "",
}))
}, [open, defaultServerId, servers])
const canSubmit = useMemo(() => {
return Boolean(
form.serverId &&
form.serverEndpoint.trim() &&
/^(\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[\w.-]+\.[a-z]{2,})$/i.test(form.serverEndpoint.trim()) &&
/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()),
)
}, [form])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<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>Инициализация IKEv2-сервера</SheetTitle>
<SheetDescription>
CA + серверный сертификат, peer (ike2/passive), пул адресов и mode-config
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<Alert variant="info">
<InfoIcon />
<AlertTitle>Выпуск CA и сертификатов занимает до минуты</AlertTitle>
<AlertDescription>
Повторный запуск безопасен: существующие managed-объекты обновятся, сертификаты
не перевыпускаются.
</AlertDescription>
</Alert>
<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) => {
const id = e.target.value
const server = servers.find((s) => s.id === id)
setForm((f) => ({ ...f, serverId: id, serverEndpoint: server?.host ?? f.serverEndpoint }))
}}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Адрес сервера (CN/SAN)" required hint="Домен или IP — по нему подключаются клиенты">
<Input
className="font-mono"
placeholder="vpn.example.com"
value={form.serverEndpoint}
onChange={(e) => set("serverEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Подсеть клиентов" required hint="Из неё пул .2–.254 и статические IP">
<Input
className="font-mono"
placeholder="10.77.0.0/24"
value={form.poolCidr}
onChange={(e) => set("poolCidr", e.target.value)}
/>
</FormField>
<FormField label="DNS для клиентов" hint="Например 10.77.0.1 или 1.1.1.1">
<Input
className="font-mono"
value={form.dns}
onChange={(e) => set("dns", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Интернет клиентам (NAT)</p>
<p className="text-xs text-muted-foreground">
Managed srcnat masquerade для подсети клиентов (out-interface-list WAN)
</p>
</div>
<FormToggle checked={form.createNatRule} onChange={(v) => set("createNatRule", v)} />
</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 { IpsecInitSheet }
+99
View File
@@ -0,0 +1,99 @@
"use client"
import type { IpsecServerSummaryDto } from "@mmapp/contracts/ipsec"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
GlobeIcon,
NetworkIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react"
function Row({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3 py-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("truncate text-xs", mono ? "font-mono" : "")} title={value}>
{value || "—"}
</span>
</div>
)
}
export interface IpsecServerGridProps {
servers: IpsecServerSummaryDto[]
onInit?: (row: IpsecServerSummaryDto) => void
onRemove?: (row: IpsecServerSummaryDto) => void
}
function IpsecServerGrid({ servers, onInit, onRemove }: IpsecServerGridProps) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => (
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{s.serverName}</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{s.serverEndpoint ?? "не инициализирован"}
</p>
</div>
{s.initialized ? (
<Badge className="gap-1">
<ShieldCheckIcon className="size-3" />
IKEv2
</Badge>
) : (
<Badge variant="outline">нет</Badge>
)}
</div>
<div className="flex flex-col divide-y divide-border/60">
<Row label="Клиенты" value={`${s.clientsOnline} онлайн / ${s.clientsTotal}`} />
<Row label="Пул адресов" value={s.pool ? `${s.pool.name}: ${s.pool.ranges}` : "—"} />
<Row label="Peer" value={s.peer ? `${s.peer.name} (${s.peer.exchangeMode ?? "ike2"}${s.peer.passive ? ", passive" : ""})` : "—"} />
<Row label="CA-сертификат" value={s.caCert ? `${s.caCert.commonName ?? s.caCert.name}${s.caCert.expiresAt ? ` · до ${s.caCert.expiresAt}` : ""}` : "—"} />
<Row label="Серверный серт." value={s.serverCert ? `${s.serverCert.commonName ?? s.serverCert.name}${s.serverCert.expiresAt ? ` · до ${s.serverCert.expiresAt}` : ""}` : "—"} />
<Row label="NAT (интернет)" value={s.natRuleManaged ? "managed masquerade" : "нет правила"} mono={false} />
</div>
<div className="mt-auto flex justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onInit?.(s)}
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
>
<GlobeIcon className="size-3.5" />
{s.initialized ? "Переинициализировать" : "Инициализировать"}
</Button>
{s.initialized && onRemove ? (
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-destructive"
aria-label="Удалить IKEv2-сервер"
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
onClick={() => onRemove(s)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
</div>
))}
{servers.length === 0 ? (
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<NetworkIcon className="size-4" />
Серверы не найдены
</div>
) : null}
</div>
)
}
export { IpsecServerGrid }
+226
View File
@@ -0,0 +1,226 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
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"
export type IpsecAuthKind = "certificate" | "pre-shared-key"
export type IpsecUserFormState = {
serverId: string
name: string
authMethod: IpsecAuthKind
psk: string
remoteId: string
useStaticIp: boolean
staticIp: string
passphrase: string
}
export const defaultIpsecUserForm = (): IpsecUserFormState => ({
serverId: "",
name: "",
authMethod: "certificate",
psk: "",
remoteId: "",
useStaticIp: false,
staticIp: "",
passphrase: "",
})
type ServerOption = { id: string; name: string; host: string }
function IpsecUserSheet({
open,
onOpenChange,
servers,
busy,
defaultServerId,
editing,
freeIpHint,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
/** Режим редактирования: сервер и метод аутентификации не меняются. */
editing?: IpsecClientDto | null
freeIpHint?: string
onSubmit: (form: IpsecUserFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<IpsecUserFormState>(defaultIpsecUserForm)
const set = <K extends keyof IpsecUserFormState>(k: K, v: IpsecUserFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
if (editing) {
const edit = editing
queueMicrotask(() => setForm({
serverId: edit.serverId,
name: edit.name,
authMethod: edit.authMethod,
psk: "",
remoteId: edit.remoteId ?? "",
useStaticIp: Boolean(edit.staticIp),
staticIp: edit.staticIp ?? "",
passphrase: "",
}))
return
}
queueMicrotask(() => setForm({ ...defaultIpsecUserForm(), serverId: defaultServerId ?? "" }))
}, [open, defaultServerId, editing])
const canSubmit = useMemo(() => {
if (!editing && !form.serverId) return false
if (!form.name.trim()) return false
if (!editing && form.authMethod === "pre-shared-key" && form.psk.trim().length < 8) return false
if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false
return true
}, [form, editing])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<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>{editing ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"}</SheetTitle>
<SheetDescription>
{editing
? "Имя, статический IP и secret (для PSK)"
: "Identity на роутере; для сертификата — выпуск .p12 для авторизации"}
</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>
{!editing ? (
<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>
) : null}
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
<Input
placeholder="alice"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
{!editing ? (
<FormField label="Аутентификация" required>
<div className="flex gap-1.5">
<Button
type="button"
size="sm"
variant={form.authMethod === "certificate" ? "secondary" : "ghost"}
onClick={() => set("authMethod", "certificate")}
>
Сертификат
</Button>
<Button
type="button"
size="sm"
variant={form.authMethod === "pre-shared-key" ? "secondary" : "ghost"}
onClick={() => set("authMethod", "pre-shared-key")}
>
PSK
</Button>
</div>
</FormField>
) : null}
{form.authMethod === "pre-shared-key" ? (
<>
<FormField label={editing ? "Новый secret (PSK)" : "Secret (PSK)"} hint={editing ? "Пусто — не менять" : "Минимум 8 символов"} required={!editing}>
<Input
className="font-mono"
type="password"
value={form.psk}
onChange={(e) => set("psk", e.target.value)}
/>
</FormField>
{!editing ? (
<FormField label="Remote ID" hint="По умолчанию — имя клиента">
<Input
className="font-mono"
placeholder="alice"
value={form.remoteId}
onChange={(e) => set("remoteId", e.target.value)}
/>
</FormField>
) : null}
</>
) : (
!editing ? (
<FormField label="Пароль архива .p12" hint="Пусто — сгенерируем автоматически">
<Input
className="font-mono"
placeholder="например MySecret123"
value={form.passphrase}
onChange={(e) => set("passphrase", e.target.value)}
/>
</FormField>
) : null
)}
</div>
<div className="flex flex-col gap-4">
<SectionTitle>IP-адрес</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Статический IP</p>
<p className="text-xs text-muted-foreground">
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
</p>
</div>
<FormToggle checked={form.useStaticIp} onChange={(v) => set("useStaticIp", v)} />
</div>
{form.useStaticIp && (
<FormField label="IP клиента" hint="Например 10.77.0.10">
<Input
className="font-mono"
placeholder={freeIpHint ?? "10.77.0.10"}
value={form.staticIp}
onChange={(e) => set("staticIp", e.target.value)}
/>
</FormField>
)}
</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 ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { IpsecUserSheet }
+245
View File
@@ -0,0 +1,245 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
BadgeCheckIcon,
KeyRoundIcon,
Trash2Icon,
UsersIcon,
WifiIcon,
} from "lucide-react"
export interface IpsecUsersGridProps {
clients: IpsecClientDto[]
compactServer?: boolean
emptyAction?: ReactNode
onDownloadCert?: (row: IpsecClientDto) => void
onEdit?: (row: IpsecClientDto) => void
onDelete?: (row: IpsecClientDto) => void
}
function IpsecUsersGrid({
clients,
compactServer = false,
emptyAction,
onDownloadCert,
onEdit,
onDelete,
}: IpsecUsersGridProps) {
const columns = useMemo<ColumnDef<IpsecClientDto>[]>(() => {
const cols: ColumnDef<IpsecClientDto>[] = [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Клиент" className="ml-1" />
),
cell: ({ row }) => {
const c = row.original
return (
<div className="flex min-w-0 items-center gap-1.5">
<UsersIcon className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
className="truncate text-left font-medium hover:underline"
onClick={() => onEdit?.(c)}
>
{c.name}
</button>
{c.disabled ? (
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
) : null}
</div>
)
},
meta: {
headerTitle: "Клиент",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "auth",
accessorKey: "authMethod",
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
cell: ({ row }) => {
const cert = row.original.authMethod === "certificate"
return (
<div className="flex items-center gap-1.5 text-xs">
{cert ? (
<BadgeCheckIcon className="size-3.5 text-info" />
) : (
<KeyRoundIcon className="size-3.5 text-muted-foreground" />
)}
{cert ? "Сертификат" : "PSK"}
</div>
)
},
meta: {
headerTitle: "Аутентификация",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
]
if (!compactServer) {
cols.push({
id: "server",
accessorKey: "serverName",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground">
{row.original.serverName}
</span>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
})
}
cols.push(
{
id: "ip",
accessorFn: (row) => row.staticIp ?? "",
header: ({ column }) => <DataGridSortHeader column={column} title="IP" />,
cell: ({ row }) => (
<span
className={cn(
"font-mono text-xs",
row.original.staticIp ? "text-foreground" : "text-muted-foreground",
)}
>
{row.original.staticIp ?? "из пула"}
</span>
),
meta: {
headerTitle: "IP",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "online",
accessorKey: "online",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span
className={cn(
"inline-flex size-1.5 rounded-full",
row.original.online ? "bg-success" : "bg-muted-foreground/40",
)}
/>
<span className={cn("font-mono text-[11px]", row.original.online ? "text-success" : "text-muted-foreground")}>
{row.original.online ? row.original.activeAddress ?? "онлайн" : "офлайн"}
</span>
</div>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
size: 96,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-0.5">
{c.authMethod === "certificate" && onDownloadCert ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Скачать сертификат"
title="Скачать .p12"
onClick={() => onDownloadCert(c)}
>
<WifiIcon className="size-3.5" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить клиента"
onClick={() => onDelete(c)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
)
return cols
}, [compactServer, onDownloadCert, onEdit, onDelete])
const table = useReactTable({
data: clients,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (clients.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет клиентов IKEv2"
description="Создайте клиента — сертификат и .p12 выпустятся автоматически"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={clients.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row"),
}}
/>
)
}
export { IpsecUsersGrid }
+2 -1
View File
@@ -55,13 +55,14 @@ import {
} from "@/lib/users"
import {
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
LockIcon, NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
} from "lucide-react"
const IFACE_TILE: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
ipsec: { icon: LockIcon, className: "text-info" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}