feat(ipsec): enhance IPsec management with peer and certificate handling
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s

- Added support for managing IPsec peers, including editing and deleting functionality.
- Introduced a new UI component for displaying peers and their details within the IPsec server grid.
- Updated the IPsec user creation form to allow binding identities to specific peers.
- Enhanced backend routes and services to handle peer patching and deletion requests.
- Improved data structures to accommodate multiple peers and certificates for each server.
- Added tests to ensure proper functionality of new peer management features.
This commit is contained in:
Denozordec
2026-09-12 21:04:49 +07:00
parent 7755d77340
commit 7365d8d8fb
14 changed files with 953 additions and 106 deletions
+146 -19
View File
@@ -11,6 +11,7 @@ import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid"
import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet"
import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet"
import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-sheet"
import { IpsecPeerSheet, type IpsecPeerFormState } from "@/components/ipsec/ipsec-peer-sheet"
import { Button } from "@/components/ui/button"
import {
Alert,
@@ -32,20 +33,25 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import {
createIpsecUser,
deleteIpsecCert,
deleteIpsecPeer,
deleteIpsecServer,
deleteIpsecUser,
exportIpsecUserCert,
initIpsecServer,
listIpsec,
patchIpsecPeer,
patchIpsecUser,
restoreIpsecRevision,
} from "@/shared/api/ipsec"
import type {
IpsecCertBundle,
IpsecCertInfoDto,
IpsecClientDto,
IpsecPeerDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
@@ -68,6 +74,23 @@ type IpsecFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete =
| { kind: "user"; client: IpsecClientDto }
| { kind: "server"; summary: IpsecServerSummaryDto }
| { kind: "peer"; serverId: string; peer: IpsecPeerDto }
| { kind: "cert"; serverId: string; cert: IpsecCertInfoDto }
const MOCK_IPSEC_PEER: IpsecPeerDto = {
id: "srv2:pp1",
rosId: "*PP1",
serverId: "srv2",
serverName: "mt-spb-edge-01",
name: "ipsec-vpn",
address: "0.0.0.0/0",
exchangeMode: "ike2",
passive: true,
certificate: "ipsec-server",
profile: "ipsec-vpn",
disabled: false,
managed: true,
}
const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
{
@@ -77,7 +100,8 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
serverEndpoint: "vpn.example.com",
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
peer: { id: "srv2:pp1", rosId: "*PP1", serverId: "srv2", serverName: "mt-spb-edge-01", name: "ipsec-vpn", address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, certificate: "ipsec-server", profile: "ipsec-vpn", disabled: false, managed: true },
peer: MOCK_IPSEC_PEER,
peers: [MOCK_IPSEC_PEER],
caCert: { name: "ipsec-ca", commonName: "MikrotikManager IPsec CA", keySize: "4096", expiresAt: "2036-09-01", trusted: true, hasPrivateKey: true, role: "ca", managed: true },
serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true },
natRuleManaged: true,
@@ -157,6 +181,8 @@ export default function IpsecPage() {
const [certOpen, setCertOpen] = useState(false)
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
const [peerOpen, setPeerOpen] = useState(false)
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
@@ -270,11 +296,19 @@ export default function IpsecPage() {
)
}, [scopedClients, search])
const serversWithIpsec = scopedSummaries.filter((s) => s.initialized).length
const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length
const clientsOnline = scopedClients.filter((c) => c.online).length
const caOk = scopedSummaries.some((s) => s.caCert)
const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined
const certNames = useMemo(
() => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(),
[scopedSummaries],
)
const peerOptions = useMemo(
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
[scopedSummaries],
)
const serverOptions = displayServers.map((s) => ({
id: s.id,
@@ -340,6 +374,7 @@ export default function IpsecPage() {
const res = await createIpsecUser(backendUrl, {
serverId: form.serverId,
name: form.name.trim(),
peerName: form.peerName.trim() || undefined,
authMethod: form.authMethod,
psk: form.authMethod === "pre-shared-key" ? form.psk : undefined,
remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined,
@@ -418,24 +453,111 @@ export default function IpsecPage() {
const confirmDelete = async () => {
if (!pendingDelete) return
const target = pendingDelete
setBusy(true)
try {
if (pendingDelete.kind === "user") {
await deleteIpsecUser(backendUrl, pendingDelete.client.serverId, pendingDelete.client.rosId)
toast.success(`Клиент «${pendingDelete.client.name}» удалён`)
if (target.kind === "user") {
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId)
toast.success(`Клиент «${target.client.name}» удалён`)
} else if (target.kind === "server") {
await deleteIpsecServer(backendUrl, target.summary.serverId)
toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`)
} else if (target.kind === "peer") {
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId)
toast.success(`Peer «${target.peer.name}» удалён`)
} else {
await deleteIpsecServer(backendUrl, pendingDelete.summary.serverId)
toast.success(`IKEv2-сервер на ${pendingDelete.summary.serverName} удалён`)
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name)
toast.success(`Сертификат «${target.cert.name}» удалён`)
}
await loadLive()
} catch (e) {
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
const forceDelete = async (fn: () => Promise<unknown>) => {
try {
await fn()
toast.success("Удалено")
await loadLive()
} catch (err) {
toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) })
}
}
if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") {
toast.error("На peer ссылаются identity", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) },
},
})
} else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") {
toast.error("Сертификат используется", {
description: e.message,
action: {
label: "Удалить принудительно",
onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) },
},
})
} else {
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
}
} finally {
setBusy(false)
setPendingDelete(null)
}
}
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
setEditingPeer({ serverId, peer })
setPeerOpen(true)
}
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
setBusy(true)
try {
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
address: form.address.trim() || undefined,
exchangeMode: form.exchangeMode.trim() || undefined,
passive: form.passive,
certificate: form.certificate.trim() || undefined,
profile: form.profile.trim() || undefined,
disabled: form.disabled,
})
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
await loadLive()
} catch (e) {
toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) })
} finally {
setBusy(false)
}
}
const deleteDialogTitle = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
case "user": return `Удалить клиента «${pendingDelete.client.name}»?`
case "peer": return `Удалить peer «${pendingDelete.peer.name}»?`
case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?`
}
})()
const deleteDialogDescription = (() => {
if (!pendingDelete) return ""
switch (pendingDelete.kind) {
case "server":
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
case "user":
return "Удалит identity, персональный mode-config и клиентский сертификат на роутере."
case "peer":
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
case "cert":
return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление."
}
})()
return (
<>
<ServerRailLayout
@@ -593,6 +715,9 @@ export default function IpsecPage() {
servers={scopedSummaries}
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
onEditPeer={isLive ? openEditPeer : undefined}
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
/>
</TabsContent>
@@ -667,6 +792,7 @@ export default function IpsecPage() {
open={userOpen}
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
servers={serverOptions}
peers={peerOptions}
busy={busy}
defaultServerId={sheetServerId}
editing={editing}
@@ -674,6 +800,15 @@ export default function IpsecPage() {
onSubmit={editing ? handleEditUser : handleCreateUser}
/>
<IpsecPeerSheet
open={peerOpen}
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
busy={busy}
editing={editingPeer?.peer ?? null}
certificates={certNames}
onSubmit={handleEditPeer}
/>
<IpsecInitSheet
open={initOpen}
onOpenChange={setInitOpen}
@@ -697,16 +832,8 @@ export default function IpsecPage() {
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>
{pendingDelete?.kind === "server"
? `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
: `Удалить клиента «${pendingDelete?.kind === "user" ? pendingDelete.client.name : ""}»?`}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "server"
? "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
: "Удалит identity, персональный mode-config и клиентский сертификат на роутере."}
</AlertDialogDescription>
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>