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