From 3eb75ea0b8be3a1d8745675eff90729961d6af51 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 12 Sep 2026 21:39:14 +0700 Subject: [PATCH] feat(ipsec): add certificate export functionality by name - Implemented a new backend route for exporting existing client certificates in .p12 format by name. - Enhanced the frontend to support exporting certificates directly from the IPsec server grid. - Updated the IPsec page to manage certificate states and handle exports effectively. - Introduced new utility functions for certificate handling and improved data structures to accommodate the changes. - Added tests to ensure the reliability of the new certificate export feature. --- app/(main)/ipsec/page.tsx | 39 +++++++- backend/src/routes/ipsec.ts | 76 +++++++++++--- .../src/services/certificate-parse.test.ts | 62 ++++++++++++ backend/src/services/certificate-parse.ts | 56 +++++++++++ backend/src/services/ipsec-ca.ts | 19 +++- backend/src/services/ipsec-config.test.ts | 46 +++++++++ backend/src/services/ipsec-config.ts | 99 ++++++++++++++++++- backend/src/services/ipsec-live.ts | 86 +++++++++++----- components/ipsec/ipsec-server-grid.tsx | 76 +++++++++----- components/ipsec/ipsec-user-sheet.tsx | 8 +- packages/contracts/src/certificates.ts | 2 + packages/contracts/src/ipsec.ts | 11 +++ shared/api/ipsec.ts | 13 +++ 13 files changed, 520 insertions(+), 73 deletions(-) create mode 100644 backend/src/services/certificate-parse.test.ts diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 4a80ef4..a91a145 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -40,6 +40,7 @@ import { deleteIpsecPeer, deleteIpsecServer, deleteIpsecUser, + exportIpsecCertByName, exportIpsecUserCert, initIpsecServer, listIpsec, @@ -97,6 +98,7 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [ serverId: "srv2", serverName: "mt-spb-edge-01", initialized: true, + ike2Ready: true, 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 }, @@ -181,6 +183,7 @@ export default function IpsecPage() { const [certOpen, setCertOpen] = useState(false) const [certBundle, setCertBundle] = useState(null) const [certClient, setCertClient] = useState(null) + const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null) const [peerOpen, setPeerOpen] = useState(false) const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null) const [pendingDelete, setPendingDelete] = useState(null) @@ -318,7 +321,7 @@ export default function IpsecPage() { /** Подсказка свободного IP из пула выбранного сервера. */ const freeIpHint = useMemo(() => { - const summary = scopedSummaries.find((s) => s.initialized && s.pool) + const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool) if (!summary?.pool) return undefined const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[] return findFreePoolIp(summary.pool.ranges, taken) ?? undefined @@ -388,6 +391,7 @@ export default function IpsecPage() { if (res.bundle) { setCertBundle(res.bundle) setCertClient(res.client ?? null) + setCertByName(null) setCertOpen(true) } } catch (e) { @@ -421,6 +425,7 @@ export default function IpsecPage() { const openCert = async (client: IpsecClientDto) => { setCertClient(client) + setCertByName(null) setCertBundle(null) setCertOpen(true) setCertBusy(true) @@ -438,11 +443,34 @@ export default function IpsecPage() { } } - const reexportCert = async (passphrase: string) => { - if (!certClient) return + /** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */ + const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => { + setCertByName({ serverId, name: cert.name }) + setCertClient(null) + setCertBundle(null) + setCertOpen(true) setCertBusy(true) try { - const bundle = await exportIpsecUserCert(backendUrl, certClient.serverId, certClient.rosId, passphrase) + 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) => { + if (!certClient && !certByName) return + setCertBusy(true) + try { + const bundle = certByName + ? 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) }) @@ -718,6 +746,7 @@ export default function IpsecPage() { onEditPeer={isLive ? openEditPeer : undefined} onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined} onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined} + onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined} /> @@ -820,7 +849,7 @@ export default function IpsecPage() { { setCertOpen(v); if (!v) setCertByName(null) }} bundle={certBundle} busy={certBusy} onReexport={reexportCert} diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index ffa83a8..98b03ec 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyReply } from "fastify" import { ipsecCertDeleteRequestSchema, + ipsecCertExportByNameRequestSchema, ipsecCertExportRequestSchema, ipsecInitRequestSchema, ipsecPeerPatchSchema, @@ -18,13 +19,16 @@ import { buildClientInstructions, buildSswanConfig, clientCertName, - findFreePoolIp, identityDisplayName, ipsecManagedComment, ipsecUserComment, + isIke2RemoteAccessIdentity, isIpsecManagedComment, parseIpsecUserComment, poolRangesFromCidr, + resolveIke2CaName, + resolveIke2ServerCert, + selectIke2Peers, userModeConfigName, } from "../services/ipsec-config.js" import { @@ -47,7 +51,7 @@ import { import { ensureCaCertificate, ensureServerCertificate, - exportClientP12, + exportCertificateP12ByName, findCertificate, issueClientCertificate, } from "../services/ipsec-ca.js" @@ -113,11 +117,11 @@ function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "a async function buildCertBundle( client: MikrotikClient, - args: { userName: string; serverEndpoint: string; passphrase: string; dns?: string }, + args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string }, ): Promise { - const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase) + const certName = args.certName?.trim() || clientCertName(args.userName) + const { fileName, content } = await exportCertificateP12ByName(client, certName, args.passphrase) const p12B64 = content.toString("base64") - const certName = clientCertName(args.userName) return { user: args.userName, serverEndpoint: args.serverEndpoint, @@ -245,26 +249,35 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { const client = MikrotikClient.fromServer(server) try { const state = await fetchIpsecState(server) + const ike2Peers = selectIke2Peers(state.peers) const peer = (body.peerName ? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim()) : undefined) - ?? state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME) + ?? ike2Peers.find((p) => isIpsecManagedComment(p.comment)) + ?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) + ?? ike2Peers[0] ?? state.peers[0] if (!peer) { return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" }) } - const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) - const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT) - ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign")) - const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) - ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) - if (body.authMethod === "certificate" && (!caCert || !serverCert)) { + const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) + const ike2Identities = state.identities.filter( + (i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames), + ) + const idMcName = ike2Identities + .map((i) => (i["mode-config"] ?? "").trim()) + .find((n) => n && !n.startsWith("mc-ipsec-")) + const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined) + ?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) + const caName = resolveIke2CaName(state.certs, serverCertRow) + if (body.authMethod === "certificate" && (!caName || !serverCertRow)) { return reply.status(400).send({ error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK", }) } const peerName = (peer.name ?? "").trim() - const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host + const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host const dns = sharedMc?.["static-dns"]?.trim() || undefined if (body.authMethod === "pre-shared-key" && !body.psk) { @@ -287,7 +300,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { if (body.authMethod === "certificate") { const issued = await issueClientCertificate(client, { userName: body.name, - caCertName: String(caCert!.name ?? IPSEC_CA_CERT), + caCertName: caName!, daysValid: body.daysValid ?? 1825, }) if (issued.existed) { @@ -301,7 +314,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { modeConfig, comment: ipsecUserComment(body.name), authMethod: body.authMethod, - certificate: body.authMethod === "certificate" ? String(serverCert?.name ?? IPSEC_SERVER_CERT) : undefined, + certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined, remoteCertificate: certName, secret: body.psk, remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined, @@ -572,6 +585,39 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { } }) + /** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */ + app.post("/ipsec/certs/:serverId/export", async (req, reply) => { + const { serverId } = req.params as { serverId: string } + const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const { name, passphrase } = parsed.data + const server = await getEnabledIpsecServerById(serverIdParam(serverId)) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + const state = await fetchIpsecState(server) + const cert = state.certs.find((c) => String(c.name ?? "").trim() === name) + if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` }) + const serverCertRow = resolveIke2ServerCert(state.certs, selectIke2Peers(state.peers)) + const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host + const userName = String(cert["common-name"] ?? "").trim() || name + const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + const dns = sharedMc?.["static-dns"]?.trim() || undefined + const bundle = await buildCertBundle(client, { + userName, + certName: name, + serverEndpoint, + passphrase, + dns, + }) + return reply.send(bundle) + } catch (e) { + return errReply(reply, e) + } + }) + app.get("/ipsec/revisions", async (req, reply) => { const q = req.query as { serverId?: string | number } const serverId = parseDbServerId(q.serverId) diff --git a/backend/src/services/certificate-parse.test.ts b/backend/src/services/certificate-parse.test.ts new file mode 100644 index 0000000..3c1cf8b --- /dev/null +++ b/backend/src/services/certificate-parse.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict" +import { + certificateRole, + isCaCertificate, + mapRosCertificateRow, + type RosCertificateRow, +} from "./certificate-parse.js" + +// Реальные серты пользователя: MyCA (root) → vpn-server, client1, anakondra. +const myCa: RosCertificateRow = { + name: "MyCA", + "common-name": "MyCA", + "key-usage": "key-cert-sign,crl-sign", + ca: "", + flags: "KAT", + authority: "true", +} +const vpnServer: RosCertificateRow = { + name: "vpn-server", + "common-name": "vpn.example.com", + "key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client", + ca: "MyCA", + flags: "KLAT", +} +const client1: RosCertificateRow = { + name: "client1", + "common-name": "client1", + "key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client", + ca: "MyCA", + flags: "KLAT", +} +const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-name": "anakondra" } + +{ + assert.ok(isCaCertificate(myCa)) + // default key-usage содержит key-cert-sign, но ca заполнен → не CA + assert.ok(!isCaCertificate(vpnServer)) + assert.ok(!isCaCertificate(client1)) +} + +{ + const ctx = { + peerCertNames: ["vpn-server"], + identityCertNames: ["client1", "anakondra"], + } + assert.equal(certificateRole(myCa, ctx), "ca") + assert.equal(certificateRole(vpnServer, ctx), "server") + // reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client + assert.equal(certificateRole(client1, ctx), "client") + assert.equal(certificateRole(anakondra, ctx), "client") + // без контекста — по key-usage (default содержит оба, поэтому server) + assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client") + assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other") +} + +{ + const dto = mapRosCertificateRow(1, "mt", client1) + assert.equal(dto?.signedByCertName, "MyCA") + assert.equal(mapRosCertificateRow(1, "mt", myCa)?.signedByCertName, undefined) +} + +console.log("certificate-parse.test.ts: ok") diff --git a/backend/src/services/certificate-parse.ts b/backend/src/services/certificate-parse.ts index 3b3f65e..f8aa83e 100644 --- a/backend/src/services/certificate-parse.ts +++ b/backend/src/services/certificate-parse.ts @@ -2,6 +2,60 @@ import type { CertificateDto } from "@mmapp/contracts/certificates" export type RosCertificateRow = Record +/** Роль сертификата в контексте IPsec/IKEv2. */ +export type CertRole = "ca" | "server" | "client" | "other" + +/** Контекст для reference-based определения роли (peer/identity ссылки точнее, чем key-usage). */ +export interface CertificateRoleContext { + /** Имена сертификатов из `peer.certificate`. */ + peerCertNames?: Iterable + /** Имена сертификатов из `identity.remote-certificate`. */ + identityCertNames?: Iterable +} + +function rosTrue(v: string | undefined): boolean { + return v === "true" || v === "yes" +} + +/** Флаги RouterOS (`flags` = строка вида "KLAT"). */ +function certFlags(row: RosCertificateRow): string { + return (row.flags ?? "").toUpperCase() +} + +/** + * CA: `key-cert-sign`, серт self-signed (read-only `ca` пуст). + * Только флаг authority/`A` не годится: RouterOS ставит `A` и серверным сертам, + * т.к. дефолтный `key-usage` содержит `key-cert-sign`. Реальный признак — отсутствие подписавшего. + */ +export function isCaCertificate(row: RosCertificateRow): boolean { + const usage = row["key-usage"] ?? "" + const signer = (row.ca ?? "").trim() + if (signer !== "") return false + return certFlags(row).includes("A") || rosTrue(row.authority) || usage.includes("key-cert-sign") +} + +/** + * Роль сертификата. Приоритет: + * 1. CA (authority / key-cert-sign без подписавшего); + * 2. серт из `peer.certificate` → server; + * 3. серт из `identity.remote-certificate` → client; + * 4. `key-usage` tls-server → server, tls-client → client (reference-based важнее: RouterOS + * дефолт содержит и tls-server, и tls-client). + */ +export function certificateRole( + row: RosCertificateRow, + ctx: CertificateRoleContext = {}, +): CertRole { + const name = (row.name ?? "").trim() + if (isCaCertificate(row)) return "ca" + if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server" + if (name && new Set(ctx.identityCertNames ?? []).has(name)) return "client" + const usage = row["key-usage"] ?? "" + if (usage.includes("tls-server")) return "server" + if (usage.includes("tls-client")) return "client" + return "other" +} + function parseRosDate(raw: string | undefined): Date | null { if (!raw?.trim()) return null const d = new Date(raw) @@ -88,6 +142,8 @@ export function mapRosCertificateRow( trusted: row.trusted === "true", status, acmeStatus: row["acme-status"]?.trim() || undefined, + /** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */ + signedByCertName: (row.ca ?? "").trim() || undefined, } } diff --git a/backend/src/services/ipsec-ca.ts b/backend/src/services/ipsec-ca.ts index e475927..edb87f8 100644 --- a/backend/src/services/ipsec-ca.ts +++ b/backend/src/services/ipsec-ca.ts @@ -107,15 +107,24 @@ export async function issueClientCertificate( return { certName, commonName: args.userName.trim(), existed: false } } +/** Экспорт произвольного сертификата по имени (существующие client1/anakondra и т.п.). */ +export async function exportCertificateP12ByName( + client: MikrotikClient, + certName: string, + passphrase: string, +): Promise<{ fileName: string; content: Buffer; certName: string }> { + const name = certName.trim() + const cert = await findCertificate(client, name) + if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`) + const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase }) + return { fileName, content, certName: name } +} + /** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */ export async function exportClientP12( client: MikrotikClient, userName: string, passphrase: string, ): Promise<{ fileName: string; content: Buffer; certName: string }> { - const certName = clientCertName(userName) - const cert = await findCertificate(client, certName) - if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`) - const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase }) - return { fileName, content, certName } + return exportCertificateP12ByName(client, clientCertName(userName), passphrase) } diff --git a/backend/src/services/ipsec-config.test.ts b/backend/src/services/ipsec-config.test.ts index 7648cba..8c12889 100644 --- a/backend/src/services/ipsec-config.test.ts +++ b/backend/src/services/ipsec-config.test.ts @@ -9,9 +9,14 @@ import { ipsecManagedComment, ipsecSlug, ipsecUserComment, + isIke2Peer, + isIke2RemoteAccessIdentity, isIpsecManagedComment, parseIpsecUserComment, poolRangesFromCidr, + resolveIke2CaName, + resolveIke2ServerCert, + selectIke2Peers, userModeConfigName, } from "./ipsec-config.js" @@ -110,4 +115,45 @@ import { assert.ok(text.includes("strongSwan")) } +// ── IKEv2-only отбор ──────────────────────────────────────────────────────── +{ + const grePeer = { name: "gre-tunnel", "exchange-mode": "ike2", address: "1.2.3.4", passive: "no" } + const s2sPeer = { name: "s2s", "exchange-mode": "main", address: "5.6.7.8", certificate: "vpn-server" } + const rwPeer = { name: "vpn-server", "exchange-mode": "ike2", address: "0.0.0.0/0", passive: "yes", certificate: "vpn-server" } + + assert.ok(isIke2Peer(rwPeer)) + assert.ok(!isIke2Peer(s2sPeer)) + + const selected = selectIke2Peers([grePeer, s2sPeer, rwPeer]) + assert.deepEqual(selected.map((p) => p.name), ["vpn-server"]) + // fallback: нет строгого набора → ike2 + сертификат + const fallback = selectIke2Peers([grePeer, { name: "ike2-cert", "exchange-mode": "ike2", certificate: "c" }]) + assert.deepEqual(fallback.map((p) => p.name), ["ike2-cert"]) + + const names = ["vpn-server"] + assert.ok(isIke2RemoteAccessIdentity( + { peer: "vpn-server", "mode-config": "ipsec-vpn", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1" }, + names, + )) + assert.ok(isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server" }, names)) + // GRE/site-to-site PSK-туннели отсеиваются + assert.ok(!isIke2RemoteAccessIdentity({ peer: "s2s", "auth-method": "pre-shared-key" }, names)) + assert.ok(!isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "pre-shared-key" }, names)) + // без ограничения по peer (набор пуст) — mode-config/серт всё равно нужен + assert.ok(isIke2RemoteAccessIdentity({ peer: "x", "mode-config": "mc" }, [])) + assert.ok(!isIke2RemoteAccessIdentity({ peer: "x", "auth-method": "pre-shared-key" }, [])) +} + +{ + const myCa = { name: "MyCA", "key-usage": "key-cert-sign,crl-sign", ca: "", flags: "KAT", authority: "true" } + const vpnServer = { name: "vpn-server", "key-usage": "digital-signature,key-encipherment,tls-server,tls-client", ca: "MyCA" } + const client1 = { name: "client1", "key-usage": "digital-signature,tls-client,tls-server", ca: "MyCA" } + const certs = [myCa, vpnServer, client1] + + assert.equal(resolveIke2ServerCert(certs, [{ name: "vpn-server", certificate: "vpn-server" }])?.name, "vpn-server") + assert.equal(resolveIke2ServerCert([myCa], [])?.name, undefined) + assert.equal(resolveIke2CaName(certs, vpnServer), "MyCA") + assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined) +} + console.log("ipsec-config.test.ts: ok") diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts index c7aceda..55eb8fd 100644 --- a/backend/src/services/ipsec-config.ts +++ b/backend/src/services/ipsec-config.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import { PRODUCT_NAME } from "../managed-markers.js" +import { isCaCertificate } from "./certificate-parse.js" // ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ───────── @@ -24,7 +25,7 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin return m?.[1] ?? null } -type IdentityLike = { +export type IdentityLike = { ".id"?: string comment?: string "remote-id"?: string @@ -32,7 +33,37 @@ type IdentityLike = { peer?: string } -type CertLike = { name?: string; "common-name"?: string } +/** Identity + поля, нужные для отбора IKEv2 remote-access. */ +export interface Ike2IdentityLike extends IdentityLike { + certificate?: string + "auth-method"?: string + "mode-config"?: string +} + +/** Peer (/ip/ipsec/peer). */ +export interface PeerLike { + ".id"?: string + name?: string + address?: string + "exchange-mode"?: string + passive?: string + certificate?: string + comment?: string +} + +export interface CertLike { + name?: string + "common-name"?: string + "key-usage"?: string + /** read-only: имя CA, которым подписан серт. */ + ca?: string + /** флаги RouterOS (строка вида "KLAT"). */ + flags?: string + authority?: string +} + +/** Строка сертификата RouterOS (raw). */ +type RosRow = Record /** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */ export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string { @@ -76,6 +107,70 @@ export function userModeConfigName(userName: string): string { return `mc-ipsec-${ipsecSlug(userName) || "client"}` } +// ── отбор IKEv2 remote-access (без GRE/site-to-site) ──────────────────────── + +function rosBool(v: string | undefined): boolean { + return v === "true" || v === "yes" +} + +function isAnyAddress(a: string | undefined): boolean { + const v = (a ?? "").trim() + return v === "" || v === "0.0.0.0/0" || v === "::/0" +} + +/** Peer с IKEv2 (exchange-mode ike2). */ +export function isIke2Peer(p: PeerLike): boolean { + return (p["exchange-mode"] ?? "").trim().toLowerCase() === "ike2" +} + +/** + * Remote-access IKEv2 peers. Строгий набор: ike2 + passive + any-address + сертификат. + * Fallback → ike2 + сертификат → любые ike2 (чтобы не потерять существующий сервер). + */ +export function selectIke2Peers(peers: T[]): T[] { + const ike2 = peers.filter(isIke2Peer) + const strict = ike2.filter( + (p) => rosBool(p.passive) && isAnyAddress(p.address) && Boolean((p.certificate ?? "").trim()), + ) + if (strict.length > 0) return strict + const withCert = ike2.filter((p) => Boolean((p.certificate ?? "").trim())) + return withCert.length > 0 ? withCert : ike2 +} + +/** + * Identity участвует в IKEv2 remote-access: привязан к IKEv2 peer И имеет mode-config + * либо сертификат клиента. PSK-туннели без mode-config/сертификата отсеиваются. + * Пустой набор peer-имён → не ограничиваем по peer. + */ +export function isIke2RemoteAccessIdentity( + i: Ike2IdentityLike, + ike2PeerNames?: Iterable, +): boolean { + const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean)) + if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false + const hasModeConfig = Boolean((i["mode-config"] ?? "").trim()) + const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim()) + const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key" + return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim())) +} + +/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */ +export function resolveIke2ServerCert(certs: T[], ike2Peers: PeerLike[]): T | undefined { + const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean)) + return certs.find((c) => names.has(String(c.name ?? "").trim())) + ?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT) + ?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) +} + +/** CA для подписи новых клиентов: `ca` серверного серта (MyCA) → managed ipsec-ca → первый CA. */ +export function resolveIke2CaName(certs: T[], serverCert?: T | null): string | undefined { + const signed = String(serverCert?.ca ?? "").trim() + if (signed && certs.some((c) => String(c.name ?? "").trim() === signed)) return signed + if (certs.some((c) => String(c.name ?? "").trim() === IPSEC_CA_CERT)) return IPSEC_CA_CERT + const ca = certs.find((c) => isCaCertificate(c)) + return ca ? String(ca.name ?? "").trim() || undefined : undefined +} + // ── пул адресов клиентов ──────────────────────────────────────────────────── function ipToInt(b: Array): number { diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index d644e81..a0742bf 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -17,8 +17,13 @@ import { IPSEC_SERVER_CERT, clientCertName, identityDisplayName, + isIke2RemoteAccessIdentity, isIpsecManagedComment, + resolveIke2CaName, + resolveIke2ServerCert, + selectIke2Peers, } from "./ipsec-config.js" +import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js" import { canonicalIpsecSnapshot, type IpsecLiveIdentity, @@ -107,13 +112,13 @@ function asBool(v: string | undefined): boolean { return v === "true" || v === "yes" } -export function mapCertificate(c: RosCertRow): IpsecCertInfoDto { +export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto { const name = String(c.name ?? "") - const keyUsage = String(c["key-usage"] ?? "") - const isCa = keyUsage.includes("key-cert-sign") - const isServer = keyUsage.includes("tls-server") - const isClient = keyUsage.includes("tls-client") const isUser = name.startsWith("ipsec-user-") + const role = certificateRole(c, { + peerCertNames: ctx.peerCertNames, + identityCertNames: ctx.identityCertNames, + }) return { name, commonName: c["common-name"] || undefined, @@ -122,8 +127,9 @@ export function mapCertificate(c: RosCertRow): IpsecCertInfoDto { expiresAt: c["invalid-after"] || undefined, trusted: asBool(c.trusted), hasPrivateKey: asBool(c["private-key"]), - role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other", - managed: isCa && name === IPSEC_CA_CERT + role, + signedBy: (c.ca ?? "").trim() || undefined, + managed: (name === IPSEC_CA_CERT && role === "ca") || name === IPSEC_SERVER_CERT || isUser || isIpsecManagedComment(c.comment), @@ -204,9 +210,10 @@ export async function fetchIpsecState(server: ServerRow): Promise (p.name ?? "").trim()).filter(Boolean) const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m])) const activeByRemote = new Map() for (const a of state.active) { @@ -215,6 +222,7 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { } return state.identities + .filter((i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames)) .map((i): IpsecClientDto => { const comment = i.comment ?? "" const managed = isIpsecManagedComment(comment) @@ -257,38 +265,70 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto { const server = state.server - const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment)) - ?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) - /** Для отображения: managed peer, иначе первый существующий peer роутера. */ - const primaryPeer = managedPeer ?? state.peers[0] - const sharedMc = state.modeConfigs.find( - (m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")), + const ike2Peers = selectIke2Peers(state.peers) + const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) + const ike2Identities = state.identities.filter( + (i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames), ) + + const managedPeer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) + ?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) + /** Для отображения: managed IKEv2 peer, иначе первый существующий IKEv2 peer. */ + const primaryPeer = managedPeer ?? ike2Peers[0] + + // mode-config, на который ссылаются IKEv2 identity (не персональный mc-ipsec-*), fallback managed shared + const idMcName = ike2Identities + .map((i) => (i["mode-config"] ?? "").trim()) + .find((n) => n && !n.startsWith("mc-ipsec-")) + const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined) + ?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) + ?? state.modeConfigs.find((m) => isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")) const pool = sharedMc ? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim()) ?? state.pools.find((p) => isIpsecManagedComment(p.comment)) : undefined - const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT) - ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign")) - const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT) - ?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) + + const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) + const caName = resolveIke2CaName(state.certs, serverCertRow) + const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined + + const roleCtx: CertificateRoleContext = { + peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean), + identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean), + } + const referencedCertNames = new Set([ + ...(roleCtx.peerCertNames ?? []), + ...(roleCtx.identityCertNames ?? []), + ...ike2Identities.map((i) => (i.certificate ?? "").trim()).filter(Boolean), + ]) + // только IKEv2-релевантные серты: CA сервера, referenced peer/identity и подписанные этим CA + const relevantCerts = state.certs.filter((c) => { + const n = String(c.name ?? "").trim() + if (caName && n === caName) return true + if (referencedCertNames.has(n)) return true + return Boolean(caName) && (c.ca ?? "").trim() === caName + }) + + const caCert = caCertRow ? mapCertificate(caCertRow, roleCtx) : undefined + const serverCert = serverCertRow ? mapCertificate(serverCertRow, roleCtx) : undefined const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat") return { serverId: String(server.id), serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), serverCountry: server.country ?? undefined, initialized: Boolean(managedPeer && caCert && serverCert), - serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined, + ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert), + serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined, peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined, - peers: state.peers.map((p) => mapPeer(server, p)), + peers: ike2Peers.map((p) => mapPeer(server, p)), pool: pool ? mapPool(server, pool) : undefined, sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined, - caCert: caCert ? mapCertificate(caCert) : undefined, - serverCert: serverCert ? mapCertificate(serverCert) : undefined, + caCert, + serverCert, natRuleManaged, clientsTotal: clients.length, clientsOnline: clients.filter((c) => c.online).length, - certs: state.certs.map(mapCertificate), + certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)), } } diff --git a/components/ipsec/ipsec-server-grid.tsx b/components/ipsec/ipsec-server-grid.tsx index 1b928d3..8742bbf 100644 --- a/components/ipsec/ipsec-server-grid.tsx +++ b/components/ipsec/ipsec-server-grid.tsx @@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { + DownloadIcon, GlobeIcon, KeyRoundIcon, NetworkIcon, @@ -91,9 +92,11 @@ function PeerRow({ function CertRow({ cert, onDelete, + onExport, }: { cert: IpsecCertInfoDto onDelete?: (cert: IpsecCertInfoDto) => void + onExport?: (cert: IpsecCertInfoDto) => void }) { const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий" return ( @@ -107,22 +110,41 @@ function CertRow({ {cert.trusted === false ? не trusted : null}

- {[cert.commonName, cert.expiresAt ? `до ${cert.expiresAt}` : undefined].filter(Boolean).join(" · ") || "—"} + {[ + cert.commonName, + cert.signedBy ? `подписан ${cert.signedBy}` : undefined, + cert.expiresAt ? `до ${cert.expiresAt}` : undefined, + ].filter(Boolean).join(" · ") || "—"}

- {onDelete ? ( - - ) : null} +
+ {onExport && cert.role === "client" ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
) } @@ -134,6 +156,7 @@ export interface IpsecServerGridProps { onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void + onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void } function IpsecServerGrid({ @@ -143,6 +166,7 @@ function IpsecServerGrid({ onEditPeer, onDeletePeer, onDeleteCert, + onExportCert, }: IpsecServerGridProps) { return (
@@ -157,14 +181,21 @@ function IpsecServerGrid({ {s.serverEndpoint ?? "endpoint не определён"}

- {s.peers.length > 0 ? ( - - - {s.peers.length} peer - - ) : ( - нет peer - )} +
+ {s.ike2Ready ? ( + + + IKEv2 готов + + ) : null} + {s.peers.length > 0 ? ( + + {s.peers.length} peer + + ) : ( + нет peer + )} +
@@ -198,6 +229,7 @@ function IpsecServerGrid({ key={c.name} cert={c} onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined} + onExport={onExportCert ? (cert) => onExportCert(s.serverId, cert) : undefined} /> ))}
diff --git a/components/ipsec/ipsec-user-sheet.tsx b/components/ipsec/ipsec-user-sheet.tsx index b92fecd..33b6468 100644 --- a/components/ipsec/ipsec-user-sheet.tsx +++ b/components/ipsec/ipsec-user-sheet.tsx @@ -151,7 +151,13 @@ function IpsecUserSheet({ /> {!editing ? ( - +