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
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:
@@ -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 }
|
||||
Reference in New Issue
Block a user