Files
MikrotikManager/components/ipsec/ipsec-cert-sheet.tsx
T
Denozordec 7755d77340
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
feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
- мастер инициализации сервера: 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
2026-09-12 20:20:56 +07:00

182 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 }