feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||
import type { IpsecExistingCertExportOptions } from "@/shared/api/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
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 {
|
||||
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
|
||||
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
|
||||
crypto.getRandomValues(bytes)
|
||||
let s = ""
|
||||
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
|
||||
return s
|
||||
}
|
||||
|
||||
function IpsecCertSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
bundle,
|
||||
busy,
|
||||
error,
|
||||
onReexport,
|
||||
certificateRole,
|
||||
targetName,
|
||||
previousAttemptFailed = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
bundle: IpsecCertBundle | null
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
||||
onReexport?: (passphrase: string, options: IpsecExistingCertExportOptions) => void | Promise<void>
|
||||
certificateRole?: "ca" | "server" | "client" | "other"
|
||||
targetName?: string
|
||||
previousAttemptFailed?: boolean
|
||||
}) {
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false)
|
||||
const [privateKeyConfirmed, setPrivateKeyConfirmed] = useState(false)
|
||||
const [caKeyConfirmed, setCaKeyConfirmed] = useState(false)
|
||||
const [newAttemptConfirmed, setNewAttemptConfirmed] = useState(false)
|
||||
const submitting = useRef(false)
|
||||
const isCa = certificateRole === "ca"
|
||||
const confirmEnabled = privateKeyConfirmed && (!isCa || caKeyConfirmed) && (!previousAttemptFailed || newAttemptConfirmed) && !busy
|
||||
const openExportConfirmation = () => {
|
||||
if (busy || submitting.current) return
|
||||
setPrivateKeyConfirmed(false)
|
||||
setCaKeyConfirmed(false)
|
||||
setNewAttemptConfirmed(false)
|
||||
setConfirmationOpen(true)
|
||||
}
|
||||
const confirmExport = async () => {
|
||||
if (!confirmEnabled || !onReexport || submitting.current) return
|
||||
submitting.current = true
|
||||
setConfirmationOpen(false)
|
||||
try {
|
||||
// One key per explicitly confirmed attempt. Nothing retries this call or
|
||||
// allocates another key after an error, opening, selection, or rendering.
|
||||
const requestKey = crypto.randomUUID()
|
||||
await onReexport(randomPassphrase(), { privateKeyExport: true, caPrivateKeyExport: isCa && caKeyConfirmed, requestKey })
|
||||
} catch {
|
||||
toast.error("Экспорт не подтверждён. Перед новой попыткой проверьте состояние роутера.")
|
||||
} finally {
|
||||
submitting.current = false
|
||||
}
|
||||
}
|
||||
const passphrase = bundle?.passphrase ?? ""
|
||||
const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_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?.user || targetName ? `«${bundle?.user ?? targetName}»` : ""}</SheetTitle>
|
||||
<SheetDescription>
|
||||
.p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Загрузка сертификата…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{previousAttemptFailed && !busy && (
|
||||
<p role="alert" className="text-sm text-destructive">Предыдущая попытка завершилась ошибкой; её результат на роутере может быть неизвестен. Проверьте временный файл и состояние экспорта перед новым запросом.</p>
|
||||
)}
|
||||
{!bundle ? (
|
||||
!busy && onReexport ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Экспорт .p12 включает закрытый ключ. Подтвердите его получение перед запросом к роутеру.</p>
|
||||
<Button variant="outline" onClick={openExportConfirmation}>{previousAttemptFailed ? "Подготовить новый запрос" : "Экспортировать .p12"}</Button>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Пароль архива .p12</SectionTitle>
|
||||
<FormField
|
||||
label="Passphrase"
|
||||
required
|
||||
hint={
|
||||
passphrase.trim().length > 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={passphrase}
|
||||
readOnly
|
||||
aria-label="Пароль архива .p12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="Подтвердить экспорт с новым паролем"
|
||||
aria-label="Подтвердить экспорт с новым паролем"
|
||||
disabled={busy || !onReexport}
|
||||
onClick={openExportConfirmation}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
{bundle.serverEndpoint ? (
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
Сервер: <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 min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle) return
|
||||
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
|
||||
toast.success(`Скачан ${bundle.filename}`)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="truncate">{bundle.filename} (.p12, сертификат + ключ)</span>
|
||||
</Button>
|
||||
{bundle.sswanContent && bundle.sswanFilename ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle.sswanContent || !bundle.sswanFilename) return
|
||||
downloadText(bundle.sswanFilename, bundle.sswanContent)
|
||||
toast.success(`Скачан ${bundle.sswanFilename}`)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="truncate">{bundle.sswanFilename} (strongSwan)</span>
|
||||
</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-y-auto overflow-x-hidden rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{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>
|
||||
<Dialog open={confirmationOpen && open} onOpenChange={setConfirmationOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подтвердить экспорт {isCa ? "CA" : "сертификата"}</DialogTitle>
|
||||
<DialogDescription>Запрос создаст на роутере временный .p12 с закрытым ключом сертификата {targetName ? `«${targetName}»` : ""}, прочитает его и удалит временный файл. Пароль архива будет сгенерирован после подтверждения.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<label htmlFor="ipsec-export-private-key" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-private-key" checked={privateKeyConfirmed} onCheckedChange={setPrivateKeyConfirmed} />
|
||||
<span>Разрешаю экспорт и получение закрытого ключа выбранного сертификата.</span>
|
||||
</label>
|
||||
{isCa && (
|
||||
<label htmlFor="ipsec-export-ca-key" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-ca-key" checked={caKeyConfirmed} onCheckedChange={setCaKeyConfirmed} />
|
||||
<span>Отдельно разрешаю экспорт закрытого ключа центра сертификации CA. Его владелец может подписывать другие сертификаты.</span>
|
||||
</label>
|
||||
)}
|
||||
{previousAttemptFailed && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-destructive">Результат предыдущего запроса может быть неизвестен. Новый запрос не продолжает и не подтверждает предыдущую попытку.</p>
|
||||
<label htmlFor="ipsec-export-new-attempt" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-new-attempt" checked={newAttemptConfirmed} onCheckedChange={setNewAttemptConfirmed} />
|
||||
<span>Я проверил состояние роутера и явно запрашиваю новый экспорт.</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmationOpen(false)}>Отмена</Button>
|
||||
<Button disabled={!confirmEnabled} onClick={() => void confirmExport()}>{previousAttemptFailed ? "Отправить новый запрос" : "Подтвердить экспорт"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecCertSheet, downloadText, downloadB64 }
|
||||
Reference in new issue
Block a user