feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+262
View File
@@ -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 }