fix(ui): сохранять контекст экспорта сертификатов IPsec
This commit is contained in:
1 parent
86ecf764ff
commit
74e7cd257d
7 files changed
+151
-67
No files matched your search
+41
-41
@@ -173,6 +173,10 @@ export default function IpsecPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [certBusy, setCertBusy] = useState(false)
|
const [certBusy, setCertBusy] = useState(false)
|
||||||
|
const [certError, setCertError] = useState<string | null>(null)
|
||||||
|
const certEpoch = useRef(0)
|
||||||
|
const certPending = useRef(false)
|
||||||
|
useEffect(() => () => { certEpoch.current += 1 }, [])
|
||||||
|
|
||||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
|
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
|
||||||
@@ -393,6 +397,10 @@ export default function IpsecPage() {
|
|||||||
setEditing(null)
|
setEditing(null)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
if (res.bundle) {
|
if (res.bundle) {
|
||||||
|
certEpoch.current += 1
|
||||||
|
certPending.current = false
|
||||||
|
setCertBusy(false)
|
||||||
|
setCertError(null)
|
||||||
setCertBundle(res.bundle)
|
setCertBundle(res.bundle)
|
||||||
setCertClient(res.client ?? null)
|
setCertClient(res.client ?? null)
|
||||||
setCertByName(null)
|
setCertByName(null)
|
||||||
@@ -421,63 +429,51 @@ export default function IpsecPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadCert = async (request: () => Promise<IpsecCertBundle>) => {
|
||||||
|
if (certPending.current) return
|
||||||
|
certPending.current = true
|
||||||
|
const epoch = ++certEpoch.current
|
||||||
|
setCertBusy(true)
|
||||||
|
setCertError(null)
|
||||||
|
try {
|
||||||
|
const bundle = await request()
|
||||||
|
if (epoch === certEpoch.current) setCertBundle(bundle)
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch === certEpoch.current) setCertError(error instanceof Error ? error.message : "Ошибка экспорта сертификата")
|
||||||
|
} finally {
|
||||||
|
if (epoch === certEpoch.current) { certPending.current = false; setCertBusy(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openCert = async (client: IpsecClientDto) => {
|
const openCert = async (client: IpsecClientDto) => {
|
||||||
const byName = client.kind === "cert" && Boolean(client.certName)
|
const byName = client.kind === "cert" && Boolean(client.certName)
|
||||||
|
certEpoch.current += 1
|
||||||
|
certPending.current = false
|
||||||
setCertClient(byName ? null : client)
|
setCertClient(byName ? null : client)
|
||||||
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||||||
setCertBundle(null)
|
setCertBundle(null)
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
setCertBusy(true)
|
const passphrase = crypto.randomUUID()
|
||||||
try {
|
await loadCert(() => byName
|
||||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||||
const bundle = byName
|
: exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase))
|
||||||
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
|
||||||
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
|
||||||
setCertBundle(bundle)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Ошибка экспорта сертификата", {
|
|
||||||
description: e instanceof Error ? e.message : String(e),
|
|
||||||
})
|
|
||||||
setCertOpen(false)
|
|
||||||
} finally {
|
|
||||||
setCertBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
|
|
||||||
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||||||
|
certEpoch.current += 1
|
||||||
|
certPending.current = false
|
||||||
setCertByName({ serverId, name: cert.name })
|
setCertByName({ serverId, name: cert.name })
|
||||||
setCertClient(null)
|
setCertClient(null)
|
||||||
setCertBundle(null)
|
setCertBundle(null)
|
||||||
setCertOpen(true)
|
setCertOpen(true)
|
||||||
setCertBusy(true)
|
await loadCert(() => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
|
||||||
try {
|
|
||||||
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
|
||||||
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
|
|
||||||
setCertBundle(bundle)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Ошибка экспорта сертификата", {
|
|
||||||
description: e instanceof Error ? e.message : String(e),
|
|
||||||
})
|
|
||||||
setCertOpen(false)
|
|
||||||
} finally {
|
|
||||||
setCertBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reexportCert = async (passphrase: string) => {
|
const reexportCert = async (passphrase: string) => {
|
||||||
if (!certClient && !certByName) return
|
if (!certClient && !certByName) return
|
||||||
setCertBusy(true)
|
await loadCert(() => certByName
|
||||||
try {
|
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||||
const bundle = certByName
|
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase))
|
||||||
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
|
||||||
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
|
|
||||||
setCertBundle(bundle)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
|
||||||
} finally {
|
|
||||||
setCertBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
@@ -850,7 +846,11 @@ export default function IpsecPage() {
|
|||||||
|
|
||||||
<IpsecCertSheet
|
<IpsecCertSheet
|
||||||
open={certOpen}
|
open={certOpen}
|
||||||
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
|
onOpenChange={(v) => {
|
||||||
|
if (!v) { certEpoch.current += 1; certPending.current = false; setCertBusy(false); setCertBundle(null); setCertClient(null); setCertByName(null) }
|
||||||
|
setCertOpen(v)
|
||||||
|
}}
|
||||||
|
error={certError}
|
||||||
bundle={certBundle}
|
bundle={certBundle}
|
||||||
busy={certBusy}
|
busy={certBusy}
|
||||||
onReexport={reexportCert}
|
onReexport={reexportCert}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -46,27 +46,19 @@ function IpsecCertSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
bundle,
|
bundle,
|
||||||
busy,
|
busy,
|
||||||
|
error,
|
||||||
onReexport,
|
onReexport,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (v: boolean) => void
|
onOpenChange: (v: boolean) => void
|
||||||
bundle: IpsecCertBundle | null
|
bundle: IpsecCertBundle | null
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
|
error?: string | null
|
||||||
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
||||||
onReexport?: (passphrase: string) => void | Promise<void>
|
onReexport?: (passphrase: string) => void | Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [passphrase, setPassphrase] = useState("")
|
const passphrase = bundle?.passphrase ?? ""
|
||||||
|
const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE)
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const initial = bundle?.passphrase ?? ""
|
|
||||||
queueMicrotask(() => setPassphrase(initial))
|
|
||||||
}, [open, bundle])
|
|
||||||
|
|
||||||
const canDownload = useMemo(
|
|
||||||
() => Boolean(bundle && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE),
|
|
||||||
[bundle, passphrase],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -79,10 +71,10 @@ function IpsecCertSheet({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
<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>}
|
||||||
{!bundle ? (
|
{!bundle ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
!busy && onReexport ? <Button variant="outline" onClick={() => void onReexport(randomPassphrase())}>Повторить экспорт</Button> : null
|
||||||
Бандл сертификата пуст — перезапустите экспорт с новой парольной фразой.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -100,17 +92,18 @@ function IpsecCertSheet({
|
|||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={passphrase}
|
value={passphrase}
|
||||||
onChange={(e) => setPassphrase(e.target.value)}
|
readOnly
|
||||||
|
aria-label="Пароль архива .p12"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
title="Сгенерировать и перекачать"
|
title="Сгенерировать и перекачать"
|
||||||
disabled={busy}
|
aria-label="Сгенерировать и перекачать"
|
||||||
|
disabled={busy || !onReexport}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = randomPassphrase()
|
const next = randomPassphrase()
|
||||||
setPassphrase(next)
|
|
||||||
if (onReexport) void onReexport(next)
|
if (onReexport) void onReexport(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
|||||||
|
|
||||||
## Текущая точка продолжения
|
## Текущая точка продолжения
|
||||||
|
|
||||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.16 — история, восстановление, формы и экспорт IPsec); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||||
|
|
||||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ flowchart LR
|
|||||||
|
|
||||||
Ближайший оставшийся объём:
|
Ближайший оставшийся объём:
|
||||||
|
|
||||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. Далее — получение и повторный экспорт сертификатов IPsec, затем GRE/VXLAN/Firewall/Filters.
|
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. В 9.14 исправлены подтверждение инициализации IPsec, сохранение формы и защита повторной отправки. В 9.15 проверены создание/редактирование пользователей IPsec и сохранение формы. В 9.16 проверены получение/повторный экспорт сертификатов, поздние ответы и соответствие отображаемого пароля архиву. Далее — удаление объектов IPsec и подтверждение API, затем GRE/VXLAN/Firewall/Filters.
|
||||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -25,12 +25,21 @@
|
|||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.15 — история, восстановление, инициализация и пользователи IPsec |
|
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.16 — история, восстановление, формы и экспорт IPsec |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 9.16 — экспорт сертификатов IPsec (2026-09-29)
|
||||||
|
|
||||||
|
- Оба API экспорта проверяют схему бандла, непустые имя/файл/содержимое, синтаксис Base64 и наличие пароля архива. Это проверка транспортного ответа, не криптографическая проверка PKCS#12. Начальный пароль генерируется crypto.randomUUID вместо Math.random.
|
||||||
|
- Общий обработчик связывает результат/ошибку/finally с открытием панели; закрытие, новое открытие и размонтирование инвалидируют старый ответ. Повтор защищён синхронным замком. Ошибка остаётся в панели с повтором; первичная загрузка объявляется через status. Физическая отмена запросов не добавлена.
|
||||||
|
- Пароль загруженного архива доступен только для чтения: изменение требует нового экспорта. При ошибке повторного экспорта остаются прежний файл и его пароль; во время запроса скачивание заблокировано. Backend может вернуть эффективный пароль, поэтому показывается именно пароль из подтверждённого ответа.
|
||||||
|
- Все 6 E2E прошли: два новых сценария экспорта (identity и имя сертификата), инициализация и три сценария пользователей. Проверены поздний успех закрытой панели, 403, неверный Base64, повторное нажатие, 500 при повторном экспорте и успешная смена пары файл/пароль. Lint изменённых файлов, TypeScript и diff-check чистые. Production build прошёл: 34/34 страницы.
|
||||||
|
- Далее: удаление IPsec — confirmDelete и ответы удаления клиента/сервера/peer/сертификата, сохранение подтверждения при ошибке и повторные нажатия. Экспорт из формы создания получает бандл по прежней схеме создания; реальные сертификаты/RouterOS/JWT не проверены. Этап 9 открыт.
|
||||||
|
- После тестов использовано 67% пятичасовой и 88% недельной квоты. Локальный коммит без push; чужие изменения сохранены.
|
||||||
|
|
||||||
## Блок 9.15 — создание и редактирование пользователей IPsec (2026-09-29)
|
## Блок 9.15 — создание и редактирование пользователей IPsec (2026-09-29)
|
||||||
|
|
||||||
- Создание проверяет ipsecUserCreatedSchema, принадлежность клиента серверу, имя и метод аутентификации. Пустой ответ, client=null и чужой сервер не считаются успехом; редактирование требует ok=true. Backend иногда возвращает client=null после применения: UI сообщает отсутствие подтверждения, а не утверждает откат операции.
|
- Создание проверяет ipsecUserCreatedSchema, принадлежность клиента серверу, имя и метод аутентификации. Пустой ответ, client=null и чужой сервер не считаются успехом; редактирование требует ok=true. Backend иногда возвращает client=null после применения: UI сообщает отсутствие подтверждения, а не утверждает откат операции.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Этап 9: VPN и сетевые операции
|
# Этап 9: VPN и сетевые операции
|
||||||
|
|
||||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.15. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
Статус: начат 2026-09-28, выполнены блоки 9.1–9.16. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||||
|
|
||||||
## Схема и референсы принятого плана
|
## Схема и референсы принятого плана
|
||||||
|
|
||||||
@@ -101,3 +101,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую
|
|||||||
## Блок 9.15 — пользователи IPsec (2026-09-29)
|
## Блок 9.15 — пользователи IPsec (2026-09-29)
|
||||||
|
|
||||||
Создание проверяет схему клиента, сервер, имя и метод; редактирование требует ok=true. Форма сохраняет ввод и ошибку, блокирует повторы/закрытие/поля до ответа; добавлены доступные имена, сброс peer при смене сервера и проверка нового PSK. Все 7 E2E, lint и TypeScript прошли. Далее — загрузка/повторный экспорт сертификатов и контекст запросов. Реальные устройства и выпуск сертификатов не проверены; последний build — 9.13.
|
Создание проверяет схему клиента, сервер, имя и метод; редактирование требует ok=true. Форма сохраняет ввод и ошибку, блокирует повторы/закрытие/поля до ответа; добавлены доступные имена, сброс peer при смене сервера и проверка нового PSK. Все 7 E2E, lint и TypeScript прошли. Далее — загрузка/повторный экспорт сертификатов и контекст запросов. Реальные устройства и выпуск сертификатов не проверены; последний build — 9.13.
|
||||||
|
|
||||||
|
## Блок 9.16 — сертификаты IPsec (2026-09-29)
|
||||||
|
|
||||||
|
Экспорт по identity/имени проверяет бандл, пароль и Base64; закрытая панель не принимает поздние ответы. Ошибка сохраняет панель для повтора; прежний архив сохраняет свой пароль при ошибке нового экспорта. Пароль только для чтения, скачивание заблокировано при запросе. Все 6 E2E, lint и TypeScript прошли; build — в журнале. Далее — удаление объектов IPsec. Реальный PKCS#12/RouterOS не проверен, сетевой запрос после закрытия не отменяется.
|
||||||
+16
-3
@@ -1,4 +1,4 @@
|
|||||||
import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema } from "@mmapp/contracts/ipsec"
|
import { ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema, ipsecCertBundleSchema } from "@mmapp/contracts/ipsec"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import type {
|
import type {
|
||||||
IpsecCertBundle,
|
IpsecCertBundle,
|
||||||
@@ -97,17 +97,29 @@ export async function deleteIpsecUser(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseExportBundle(response: unknown): IpsecCertBundle {
|
||||||
|
const result = ipsecCertBundleSchema.safeParse(response)
|
||||||
|
if (!result.success || !result.data.filename.trim() || !result.data.user.trim() ||
|
||||||
|
!result.data.passphrase || result.data.passphrase.length < 8 ||
|
||||||
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(result.data.contentB64) ||
|
||||||
|
!result.data.contentB64) {
|
||||||
|
throw new Error("Некорректный ответ экспорта сертификата")
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportIpsecUserCert(
|
export async function exportIpsecUserCert(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
): Promise<IpsecCertBundle> {
|
): Promise<IpsecCertBundle> {
|
||||||
return requestJson<IpsecCertBundle>(
|
const response = await requestJson<unknown>(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}/cert`,
|
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}/cert`,
|
||||||
{ method: "POST", body: JSON.stringify({ passphrase }) },
|
{ method: "POST", body: JSON.stringify({ passphrase }) },
|
||||||
)
|
)
|
||||||
|
return parseExportBundle(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportIpsecCertByName(
|
export async function exportIpsecCertByName(
|
||||||
@@ -116,11 +128,12 @@ export async function exportIpsecCertByName(
|
|||||||
name: string,
|
name: string,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
): Promise<IpsecCertBundle> {
|
): Promise<IpsecCertBundle> {
|
||||||
return requestJson<IpsecCertBundle>(
|
const response = await requestJson<unknown>(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
|
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
|
||||||
{ method: "POST", body: JSON.stringify({ name, passphrase }) },
|
{ method: "POST", body: JSON.stringify({ name, passphrase }) },
|
||||||
)
|
)
|
||||||
|
return parseExportBundle(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreIpsecRevision(
|
export async function restoreIpsecRevision(
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
|
||||||
|
for (const byName of [false, true]) {
|
||||||
|
test(`IPsec: экспорт ${byName ? "по имени" : "по identity"} сохраняет контекст и пароль архива`, async ({ page }) => {
|
||||||
|
const client = { id: "1", rosId: "*P/1", serverId: "1", serverName: "Роутер", name: "alice", authMethod: "certificate", kind: "cert", certName: byName ? "alice-cert" : undefined, online: false, disabled: false, managed: true }
|
||||||
|
let attempts = 0
|
||||||
|
let release: (() => void) | undefined
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
|
let confirmedPassphrase = ""
|
||||||
|
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||||
|
await page.route("**/api/**", async route => {
|
||||||
|
const path = new URL(route.request().url()).pathname
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
attempts++
|
||||||
|
expect(decodeURIComponent(path)).toBe(byName ? "/api/ipsec/certs/1/export" : "/api/ipsec/users/1/*P/1/cert")
|
||||||
|
const body = route.request().postDataJSON()
|
||||||
|
if (byName) expect(body.name).toBe("alice-cert")
|
||||||
|
expect(body.passphrase.length).toBeGreaterThanOrEqual(8)
|
||||||
|
if (attempts === 1) { await gate; return route.fulfill({ json: { user: "stale", filename: "stale.p12", contentB64: "YQ==", passphrase: body.passphrase } }) }
|
||||||
|
if (attempts === 2) return route.fulfill({ status: 403, json: { error: "Экспорт запрещён тест" } })
|
||||||
|
if (attempts === 3) return route.fulfill({ json: { user: "alice", filename: "alice.p12", contentB64: "!invalid!", passphrase: body.passphrase } })
|
||||||
|
if (attempts === 5) return route.fulfill({ status: 500, json: { error: "Повторный экспорт не выполнен" } })
|
||||||
|
confirmedPassphrase = body.passphrase
|
||||||
|
return route.fulfill({ json: { user: "alice", filename: "alice.p12", contentB64: "YQ==", passphrase: body.passphrase } })
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
|
||||||
|
: path === "/api/ipsec" ? { servers: [], clients: [client] }
|
||||||
|
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
||||||
|
})
|
||||||
|
await page.goto("/ipsec")
|
||||||
|
const open = page.getByRole("button", { name: "Скачать сертификат", exact: true })
|
||||||
|
await open.click()
|
||||||
|
const sheet = page.getByRole("dialog")
|
||||||
|
await expect(sheet.getByRole("status")).toContainText("Загрузка сертификата")
|
||||||
|
await expect.poll(() => attempts).toBe(1)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(sheet).toHaveCount(0)
|
||||||
|
await open.click()
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("Экспорт запрещён тест")
|
||||||
|
const oldResponse = page.waitForResponse(r => r.request().method() === "POST")
|
||||||
|
release!()
|
||||||
|
await (await oldResponse).finished()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("Экспорт запрещён тест")
|
||||||
|
await expect(sheet.getByText("stale.p12", { exact: false })).toHaveCount(0)
|
||||||
|
const retry = sheet.getByRole("button", { name: "Повторить экспорт" })
|
||||||
|
await retry.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||||
|
await expect.poll(() => attempts).toBe(3)
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ экспорта")
|
||||||
|
await retry.click()
|
||||||
|
const password = sheet.getByLabel("Пароль архива .p12")
|
||||||
|
await expect(password).toHaveValue(confirmedPassphrase)
|
||||||
|
await expect(password).toHaveAttribute("readonly", "")
|
||||||
|
const original = confirmedPassphrase
|
||||||
|
await sheet.getByRole("button", { name: "Сгенерировать и перекачать" }).click()
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("Повторный экспорт не выполнен")
|
||||||
|
await expect(password).toHaveValue(original)
|
||||||
|
await expect(sheet.getByRole("button", { name: /alice.p12/ })).toBeEnabled()
|
||||||
|
await sheet.getByRole("button", { name: "Сгенерировать и перекачать" }).click()
|
||||||
|
await expect.poll(() => attempts).toBe(6)
|
||||||
|
await expect(password).not.toHaveValue(original)
|
||||||
|
await expect(password).toHaveValue(confirmedPassphrase)
|
||||||
|
await expect(sheet.getByRole("alert")).toHaveCount(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user