diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index a91a145..5f58f90 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -116,18 +116,18 @@ const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [ const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [ { id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01", - name: "alice", authMethod: "certificate", certificateName: "ipsec-user-alice", commonName: "alice", + name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice", staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn", online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true, }, { id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01", - name: "bob", authMethod: "certificate", certificateName: "ipsec-user-bob", commonName: "bob", + name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob", peerName: "ipsec-vpn", online: false, disabled: false, managed: true, }, { id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01", - name: "tablet-psk", authMethod: "pre-shared-key", remoteId: "tablet", + name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet", peerName: "ipsec-vpn", online: false, disabled: false, managed: true, }, ] @@ -424,14 +424,17 @@ export default function IpsecPage() { } const openCert = async (client: IpsecClientDto) => { - setCertClient(client) - setCertByName(null) + const byName = client.kind === "cert" && Boolean(client.certName) + setCertClient(byName ? null : client) + setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null) setCertBundle(null) setCertOpen(true) setCertBusy(true) try { const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}` - const bundle = await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase) + const bundle = byName + ? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase) + : await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase) setCertBundle(bundle) } catch (e) { toast.error("Ошибка экспорта сертификата", { diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index 98b03ec..0520cda 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -29,6 +29,7 @@ import { resolveIke2CaName, resolveIke2ServerCert, selectIke2Peers, + selectSharedIke2Identity, userModeConfigName, } from "../services/ipsec-config.js" import { @@ -46,6 +47,7 @@ import { deleteIdentity, patchPeer, deletePeer, + ensureSharedIke2Identity, listByPath, } from "../services/ipsec-ros.js" import { @@ -284,6 +286,9 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" }) } + const sharedIdentity = selectSharedIke2Identity(state.identities, ike2PeerNames) + const sharedIdentityId = sharedIdentity?.[".id"] ? String(sharedIdentity[".id"]) : undefined + let modeConfig = (sharedMc?.name ?? "").trim() if (body.staticIp) { const ip = body.staticIp.trim() @@ -309,16 +314,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { certName = issued.certName } - await putIdentity(client, { - peerName, - modeConfig, - comment: ipsecUserComment(body.name), - authMethod: body.authMethod, - 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, - }) + if (body.authMethod === "certificate" && !body.staticIp) { + // Гибрид: клиент «вешается» на общую identity peer — отдельная identity не нужна, + // RouterOS сам принимает любой клиентский серт, подписанный доверенным CA. + await ensureSharedIke2Identity(client, { + peerName, + serverCertName: String(serverCertRow?.name ?? IPSEC_SERVER_CERT), + modeConfigName: modeConfig, + comment: ipsecManagedComment("IKEv2 listener"), + }) + } else { + // PSK или персональный статический IP: отдельная identity (match-by=certificate для cert). + // place-before гарантирует матчинг раньше общей listener-identity. + await putIdentity(client, { + peerName, + modeConfig, + comment: ipsecUserComment(body.name), + authMethod: body.authMethod, + 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, + placeBefore: sharedIdentityId, + }) + } let bundle: IpsecCertBundle | undefined if (body.authMethod === "certificate" && certName) { @@ -355,21 +374,41 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { const client = MikrotikClient.fromServer(server) try { const state = await fetchIpsecState(server) - const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) - if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) - const managedIdentity = isIpsecManagedComment(identity.comment) - const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? "" + const targetRosId = rosIdParam(rosId) + const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId) + const certRow = identity + ? undefined + : state.certs.find((c) => String(c[".id"] ?? "") === targetRosId) + ?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId) + if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" }) + + const ike2Peers = selectIke2Peers(state.peers) + const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) + const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) + const managedIdentity = identity + ? isIpsecManagedComment(identity.comment) + : String(certRow?.name ?? "").startsWith("ipsec-user-") + const oldName = identity + ? (parseIpsecUserComment(identity.comment) ?? identity.comment ?? "") + : String(certRow?.["common-name"] ?? certRow?.name ?? "").trim() const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) const sharedMcName = (sharedMc?.name ?? "").trim() + const newName = d.name?.trim() || oldName if (d.name && d.name !== oldName) { - // managed: user=; существующий RouterOS identity: обычный comment - await patchIdentity(client, identity[".id"]!, { - comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(), - }) + if (identity) { + // managed: user=; существующий RouterOS identity: обычный comment + await patchIdentity(client, identity[".id"]!, { + comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(), + }) + } + // managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем) + if (certRow?.[".id"] && managedIdentity) { + await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) }) + } } - if (d.staticIp !== undefined) { + if (identity && d.staticIp !== undefined) { const currentMc = (identity["mode-config"] ?? "").trim() if (d.staticIp == null) { // вернуть выдачу из пула @@ -383,18 +422,40 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc), ) if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) - const name = userModeConfigName(d.name || oldName) - await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`)) + const name = userModeConfigName(newName) + await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`)) await patchIdentity(client, identity[".id"]!, { "mode-config": name }) } + } else if (!identity && certRow && d.staticIp) { + // cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config + const ip = d.staticIp.trim() + if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) + const taken = takenStaticIps(state.modeConfigs) + if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) + const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0] + if (!peer) return reply.status(400).send({ error: "На роутере нет IKEv2 peer для привязки клиента" }) + const shared = selectSharedIke2Identity(state.identities, ike2PeerNames) + const mcName = userModeConfigName(newName) + await putUserModeConfig(client, mcName, ip, ipsecManagedComment(`клиент ${newName.trim()}`)) + await putIdentity(client, { + peerName: (peer.name ?? "").trim(), + modeConfig: mcName, + comment: ipsecUserComment(newName), + authMethod: "certificate", + certificate: String(serverCertRow?.name ?? IPSEC_SERVER_CERT), + remoteCertificate: String(certRow.name ?? "").trim(), + placeBefore: shared?.[".id"] ? String(shared[".id"]) : undefined, + }) } - const patch: Record = {} - if (d.psk) patch.secret = d.psk - if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId - if (d.disabled === true) patch.disabled = "yes" - if (d.disabled === false) patch.disabled = "no" - if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch) + if (identity) { + const patch: Record = {} + if (d.psk) patch.secret = d.psk + if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId + if (d.disabled === true) patch.disabled = "yes" + if (d.disabled === false) patch.disabled = "no" + if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch) + } await recordIpsec(server, "apply") return reply.send({ ok: true }) @@ -412,20 +473,36 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { const client = MikrotikClient.fromServer(server) try { const state = await fetchIpsecState(server) - const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) - if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) - const managed = isIpsecManagedComment(identity.comment) - const userName = parseIpsecUserComment(identity.comment) ?? "" + const targetRosId = rosIdParam(rosId) + const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId) + const certRow = identity + ? undefined + : state.certs.find((c) => String(c[".id"] ?? "") === targetRosId) + ?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId) + if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" }) + const managed = identity + ? isIpsecManagedComment(identity.comment) + : String(certRow?.name ?? "").startsWith("ipsec-user-") + const userName = identity ? (parseIpsecUserComment(identity.comment) ?? "") : "" const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim() - const personal = (identity["mode-config"] ?? "").trim() - const remoteCert = (identity["remote-certificate"] ?? "").trim() + const personal = identity ? (identity["mode-config"] ?? "").trim() : "" + const remoteCert = identity + ? (identity["remote-certificate"] ?? "").trim() + : String(certRow?.name ?? "").trim() - await deleteIdentity(client, identity[".id"]!) - if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal) + if (identity) { + await deleteIdentity(client, identity[".id"]!) + if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal) + } if (removeCertificate) { - // только явный сертификат клиента или managed-identity; чужие сертификаты не трогаем - if (remoteCert) await client.removeCertificate(remoteCert).catch(() => undefined) - else if (managed && userName) await client.removeCertificate(clientCertName(userName)).catch(() => undefined) + // удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем + const ike2Peers = selectIke2Peers(state.peers) + const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers)) + const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim() + const certName = remoteCert || (managed && userName ? clientCertName(userName) : "") + if (certName && certName !== caName && certName !== serverCertName) { + await client.removeCertificate(certName).catch(() => undefined) + } } await recordIpsec(server, "apply") return reply.send({ ok: true }) diff --git a/backend/src/services/ipsec-config.test.ts b/backend/src/services/ipsec-config.test.ts index 8c12889..92a6a28 100644 --- a/backend/src/services/ipsec-config.test.ts +++ b/backend/src/services/ipsec-config.test.ts @@ -17,8 +17,10 @@ import { resolveIke2CaName, resolveIke2ServerCert, selectIke2Peers, + selectSharedIke2Identity, userModeConfigName, } from "./ipsec-config.js" +import { identityRosBody } from "./ipsec-ros.js" { assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper") @@ -156,4 +158,34 @@ import { assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined) } +{ + // Общая listener-identity: без remote-certificate, сертификатный auth. + const shared = { ".id": "*S", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", "mode-config": "ikev2-modeconf" } + const perClient = { ".id": "*C", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1", "match-by": "certificate" } + const psk = { ".id": "*P", peer: "vpn-server", "auth-method": "pre-shared-key" } + const foreignPeer = { ".id": "*F", peer: "gre-tunnel", "auth-method": "rsa-key", certificate: "vpn-server" } + + assert.equal(selectSharedIke2Identity([perClient, psk, foreignPeer, shared], ["vpn-server"])?.[".id"], "*S") + // только per-client identity (с remote-certificate) общей не считается + assert.equal(selectSharedIke2Identity([perClient, psk], ["vpn-server"]), undefined) + // managed-приоритет + const managed = { ".id": "*M", peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server", comment: ipsecManagedComment("listener") } + assert.equal(selectSharedIke2Identity([shared, managed], ["vpn-server"])?.[".id"], "*M") +} + +{ + const body = identityRosBody({ + peerName: "vpn-server", + modeConfig: "mc-ipsec-alice", + comment: ipsecUserComment("alice"), + authMethod: "certificate", + certificate: "vpn-server", + remoteCertificate: "ipsec-user-alice", + placeBefore: "*S", + }) + assert.equal(body["place-before"], "*S") + assert.equal(body["remote-certificate"], "ipsec-user-alice") + assert.equal(body["match-by"], "certificate") +} + console.log("ipsec-config.test.ts: ok") diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts index 55eb8fd..8a42ea5 100644 --- a/backend/src/services/ipsec-config.ts +++ b/backend/src/services/ipsec-config.ts @@ -154,6 +154,27 @@ export function isIke2RemoteAccessIdentity( return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim())) } +/** + * Общая listener-identity на IKEv2 peer: `remote-certificate` пуст, auth-method сертификатный. + * Именно она обслуживает всех клиентов, чей серт подписан доверенным CA (RouterOS валидирует по CA, + * см. docs «If a remote-certificate is not specified then the received certificate is checked against CA»). + * Приоритет managed, иначе первая подходящая (на устройстве это `denozord`/`ikev2-peer`). + */ +export function selectSharedIke2Identity( + identities: T[], + ike2PeerNames?: Iterable, +): T | undefined { + const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean)) + const candidates = identities.filter((i) => { + if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false + if ((i["remote-certificate"] ?? "").trim() !== "") return false + const auth = (i["auth-method"] ?? "").trim().toLowerCase() + if (auth !== "rsa-key" && auth !== "digital-signature") return false + return Boolean((i.certificate ?? "").trim()) + }) + return candidates.find((i) => isIpsecManagedComment(i.comment)) ?? candidates[0] +} + /** Серверный серт 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)) diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index a0742bf..cca0b3d 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -22,6 +22,7 @@ import { resolveIke2CaName, resolveIke2ServerCert, selectIke2Peers, + selectSharedIke2Identity, } from "./ipsec-config.js" import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js" import { @@ -210,57 +211,120 @@ export async function fetchIpsecState(server: ServerRow): Promise (p.name ?? "").trim()).filter(Boolean) + const ike2Peers = selectIke2Peers(state.peers) + const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m])) + const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id) + + const roleCtx: CertificateRoleContext = { + peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean), + identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean), + } + const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers)) + const shared = selectSharedIke2Identity(state.identities, ike2PeerNames) + const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined + const activeByRemote = new Map() for (const a of state.active) { const rid = String(a["remote-id"] ?? "").trim() if (rid) activeByRemote.set(rid, a) } - return state.identities - .filter((i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames)) - .map((i): IpsecClientDto => { - const comment = i.comment ?? "" - const managed = isIpsecManagedComment(comment) - const name = identityDisplayName(i, state.certs) - const psk = (i["auth-method"] ?? "") === "pre-shared-key" - const certName = (i["remote-certificate"] ?? "").trim() - const cn = certName - ? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "") - : "" - const mcName = (i["mode-config"] ?? "").trim() - const mc = mcByName.get(mcName) - const staticIp = mc - ? ((mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined) - : undefined - const active = (cn ? activeByRemote.get(cn) : undefined) - ?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined) - return { - id: `${server.id}:${String(i[".id"] ?? "identity")}`, - rosId: String(i[".id"] ?? "identity"), - serverId: String(server.id), - serverName: String(server.name ?? "").trim() || String(server.host ?? server.id), - name, - authMethod: psk ? "pre-shared-key" : "certificate", - certificateName: certName || undefined, - commonName: cn || undefined, - remoteId: (i["remote-id"] ?? "").trim() || undefined, - staticIp, - modeConfigName: mcName || undefined, - peerName: (i.peer ?? "").trim() || undefined, - online: Boolean(active), - activeAddress: active?.address || undefined, - activeSince: active?.established || undefined, - disabled: asBool(i.disabled), - comment: comment || undefined, - managed, - } + const identityByRemoteCert = new Map() + for (const i of state.identities) { + const rc = (i["remote-certificate"] ?? "").trim() + if (rc) identityByRemoteCert.set(rc, i) + } + + const personalStaticIp = (mcNameRaw: string): string | undefined => { + const mc = mcByName.get(mcNameRaw) + if (!mc) return undefined + return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined + } + + const out: IpsecClientDto[] = [] + + // 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity. + for (const cert of state.certs) { + const certName = String(cert.name ?? "").trim() + if (!certName) continue + if (certificateRole(cert, roleCtx) !== "client") continue + const signedBy = (cert.ca ?? "").trim() + const isUser = certName.startsWith("ipsec-user-") + const referenced = identityByRemoteCert.has(certName) + if (!isUser && !referenced && !(caName && signedBy === caName)) continue + + const identity = identityByRemoteCert.get(certName) + const comment = identity?.comment ?? "" + const mcName = (identity?.["mode-config"] ?? "").trim() + const cn = String(cert["common-name"] ?? "").trim() + const active = (cn ? activeByRemote.get(cn) : undefined) + ?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined) + out.push({ + id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`, + rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName), + serverId: String(server.id), + serverName, + name: cn || certName, + authMethod: "certificate", + kind: "cert", + certificateName: certName, + certName, + signedBy: signedBy || undefined, + commonName: cn || undefined, + remoteId: (identity?.["remote-id"] ?? "").trim() || undefined, + staticIp: personalStaticIp(mcName), + modeConfigName: mcName || undefined, + peerName: (identity?.peer ?? "").trim() || undefined, + online: Boolean(active), + activeAddress: active?.address || undefined, + activeSince: active?.established || undefined, + disabled: asBool(identity?.disabled), + comment: comment || undefined, + managed: isUser || isIpsecManagedComment(comment), }) - .sort((a, b) => a.name.localeCompare(b.name)) + } + + // 2. PSK-identity (managed или IKEv2 remote-access). + for (const i of state.identities) { + if ((i["auth-method"] ?? "").trim().toLowerCase() !== "pre-shared-key") continue + const rosId = String(i[".id"] ?? "") + if (sharedRosId && rosId === sharedRosId) continue + const managed = isIpsecManagedComment(i.comment) + if (!managed && !isIke2RemoteAccessIdentity(i, ike2PeerNames)) continue + const mcName = (i["mode-config"] ?? "").trim() + const remoteId = (i["remote-id"] ?? "").trim() + const active = remoteId ? activeByRemote.get(remoteId) : undefined + out.push({ + id: `${server.id}:${rosId || "identity"}`, + rosId: rosId || "identity", + serverId: String(server.id), + serverName, + name: identityDisplayName(i, state.certs), + authMethod: "pre-shared-key", + kind: "psk", + remoteId: remoteId || undefined, + staticIp: personalStaticIp(mcName), + modeConfigName: mcName || undefined, + peerName: (i.peer ?? "").trim() || undefined, + online: Boolean(active), + activeAddress: active?.address || undefined, + activeSince: active?.established || undefined, + disabled: asBool(i.disabled), + comment: i.comment || undefined, + managed, + }) + } + + return out.sort((a, b) => a.name.localeCompare(b.name)) } export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto { diff --git a/backend/src/services/ipsec-ros.ts b/backend/src/services/ipsec-ros.ts index 2d1141c..c25ab69 100644 --- a/backend/src/services/ipsec-ros.ts +++ b/backend/src/services/ipsec-ros.ts @@ -1,5 +1,11 @@ import type { MikrotikClient } from "./mikrotik.js" -import { ipsecManagedComment } from "./ipsec-config.js" +import { + ipsecManagedComment, + selectIke2Peers, + selectSharedIke2Identity, + type Ike2IdentityLike, + type PeerLike, +} from "./ipsec-config.js" export function toRosBody(obj: Record): Record { const out: Record = {} @@ -177,6 +183,8 @@ export interface IdentityFields { /** psk. */ secret?: string remoteId?: string + /** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */ + placeBefore?: string } export function identityRosBody(f: IdentityFields): Record { @@ -190,6 +198,7 @@ export function identityRosBody(f: IdentityFields): Record { "remote-id": f.remoteId, "mode-config": f.modeConfig, "generate-policy": "port-strict", + "place-before": f.placeBefore, comment: f.comment, }) } @@ -198,6 +207,33 @@ export async function putIdentity(client: MikrotikClient, fields: IdentityFields await client.put("/ip/ipsec/identity", identityRosBody(fields)) } +/** + * Общая listener-identity на IKEv2 peer для всех клиентов (reuse существующей, иначе создать). + * Без `remote-certificate`/`match-by` — RouterOS принимает любой клиентский серт, подписанный доверенным CA. + */ +export async function ensureSharedIke2Identity( + client: MikrotikClient, + args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string }, +): Promise { + const identities = await listByPath(client, "/ip/ipsec/identity") + const peers = await listByPath(client, "/ip/ipsec/peer") + const peerNames = selectIke2Peers(peers as PeerLike[]).map((p) => (p.name ?? "").trim()).filter(Boolean) + const existing = selectSharedIke2Identity( + identities as Ike2IdentityLike[], + peerNames.length > 0 ? peerNames : [args.peerName], + ) + if (existing?.[".id"]) return String(existing[".id"]) + + await putIdentity(client, { + peerName: args.peerName, + modeConfig: args.modeConfigName ?? "", + comment: args.comment, + authMethod: "certificate", + certificate: args.serverCertName, + }) + return undefined +} + export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record): Promise { await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body) } diff --git a/components/ipsec/ipsec-user-sheet.tsx b/components/ipsec/ipsec-user-sheet.tsx index 33b6468..dcc6ac5 100644 --- a/components/ipsec/ipsec-user-sheet.tsx +++ b/components/ipsec/ipsec-user-sheet.tsx @@ -155,7 +155,7 @@ function IpsecUserSheet({ label="Аутентификация" required hint={form.authMethod === "certificate" - ? "CA и серверный серт берутся из уже настроенного IKEv2 (напр. MyCA + vpn-server); новый клиент подписывается этим CA" + ? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)" : undefined} >
@@ -220,6 +220,7 @@ function IpsecUserSheet({

Статический IP

{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"} + {" "}Статический IP создаёт персональную identity на peer.

set("useStaticIp", v)} /> diff --git a/components/ipsec/ipsec-users-grid.tsx b/components/ipsec/ipsec-users-grid.tsx index 996f8f2..9549f1b 100644 --- a/components/ipsec/ipsec-users-grid.tsx +++ b/components/ipsec/ipsec-users-grid.tsx @@ -84,15 +84,26 @@ function IpsecUsersGrid({ accessorKey: "authMethod", header: ({ column }) => , cell: ({ row }) => { - const cert = row.original.authMethod === "certificate" + const c = row.original + const cert = c.kind === "cert" return ( -
- {cert ? ( - - ) : ( - - )} - {cert ? "Сертификат" : "PSK"} +
+
+ {cert ? ( + + ) : ( + + )} + {cert ? "Сертификат" : "PSK"} +
+ {cert && c.signedBy ? ( + + подписан: {c.signedBy} + + ) : null}
) }, @@ -175,7 +186,7 @@ function IpsecUsersGrid({ const c = row.original return (
- {c.authMethod === "certificate" && onDownloadCert ? ( + {c.kind === "cert" && onDownloadCert ? (