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
+134 -18
View File
@@ -1,8 +1,10 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply } from "fastify"
import {
ipsecCertDeleteRequestSchema,
ipsecCertExportRequestSchema,
ipsecInitRequestSchema,
ipsecPeerPatchSchema,
ipsecUserCreateRequestSchema,
ipsecUserPatchSchema,
type IpsecCertBundle,
@@ -17,6 +19,7 @@ import {
buildSswanConfig,
clientCertName,
findFreePoolIp,
identityDisplayName,
ipsecManagedComment,
ipsecUserComment,
isIpsecManagedComment,
@@ -37,6 +40,8 @@ import {
putIdentity,
patchIdentity,
deleteIdentity,
patchPeer,
deletePeer,
listByPath,
} from "../services/ipsec-ros.js"
import {
@@ -240,22 +245,33 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const peer = state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
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)
?? 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)
if (!peer || !sharedMc || !caCert || !serverCert) {
return reply.status(400).send({ error: "IKEv2-сервер не инициализирован — запустите мастер на вкладке «Сервер»" })
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
if (body.authMethod === "certificate" && (!caCert || !serverCert)) {
return reply.status(400).send({
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
})
}
const peerName = (peer.name ?? "").trim()
const serverEndpoint = String(serverCert["common-name"] ?? "").trim()
const dns = sharedMc["static-dns"]?.trim() || undefined
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
const dns = sharedMc?.["static-dns"]?.trim() || undefined
if (body.authMethod === "pre-shared-key" && !body.psk) {
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
}
let modeConfig = (sharedMc.name ?? "").trim()
let modeConfig = (sharedMc?.name ?? "").trim()
if (body.staticIp) {
const ip = body.staticIp.trim()
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
@@ -271,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (body.authMethod === "certificate") {
const issued = await issueClientCertificate(client, {
userName: body.name,
caCertName: IPSEC_CA_CERT,
caCertName: String(caCert!.name ?? IPSEC_CA_CERT),
daysValid: body.daysValid ?? 1825,
})
if (issued.existed) {
@@ -285,7 +301,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
modeConfig,
comment: ipsecUserComment(body.name),
authMethod: body.authMethod,
certificate: body.authMethod === "certificate" ? IPSEC_SERVER_CERT : undefined,
certificate: body.authMethod === "certificate" ? String(serverCert?.name ?? IPSEC_SERVER_CERT) : undefined,
remoteCertificate: certName,
secret: body.psk,
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
@@ -328,26 +344,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
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 sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const sharedMcName = (sharedMc?.name ?? "").trim()
if (d.name && d.name !== oldName) {
// managed: user=<name>; существующий RouterOS identity: обычный comment
await patchIdentity(client, identity[".id"]!, {
comment: ipsecUserComment(d.name),
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
})
}
if (d.staticIp !== undefined) {
const currentMc = (identity["mode-config"] ?? "").trim()
if (d.staticIp == null) {
// вернуть выдачу из пула
if (sharedMc) await patchIdentity(client, identity[".id"]!, { "mode-config": (sharedMc.name ?? "").trim() })
const personal = (identity["mode-config"] ?? "").trim()
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
if (sharedMcName) await patchIdentity(client, identity[".id"]!, { "mode-config": sharedMcName })
const personal = currentMc && currentMc !== sharedMcName ? currentMc : ""
if (personal) await deleteUserModeConfig(client, personal)
} else {
const ip = d.staticIp.trim()
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const others = takenStaticIps(
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== (identity["mode-config"] ?? "").trim()),
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)
@@ -381,15 +401,18 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
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 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()
await deleteIdentity(client, identity[".id"]!)
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
if (removeCertificate && identity["remote-certificate"]) {
await client.removeCertificate(identity["remote-certificate"]).catch(() => undefined)
} else if (removeCertificate && userName) {
await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
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)
}
await recordIpsec(server, "apply")
return reply.send({ ok: true })
@@ -398,6 +421,99 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
app.patch("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = ipsecPeerPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const d = 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 peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
const body: Record<string, string> = {}
if (d.name !== undefined) body.name = d.name
if (d.address !== undefined) body.address = d.address
if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode
if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no"
if (d.certificate !== undefined) body.certificate = d.certificate
if (d.profile !== undefined) body.profile = d.profile
if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no"
if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body)
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.delete("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const q = req.query as { force?: string }
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 peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
const peerName = (peer.name ?? "").trim()
const linked = state.identities
.filter((i) => (i.peer ?? "").trim() === peerName)
.map((i) => identityDisplayName(i, state.certs))
if (linked.length > 0 && q.force !== "true") {
return reply.status(409).send({
error: `На peer «${peerName}» ссылаются identity: ${linked.join(", ")}. Удаление разорвёт их.`,
identities: linked,
})
}
await deletePeer(client, rosIdParam(rosId))
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.delete("/ipsec/certs/:serverId", async (req, reply) => {
const { serverId } = req.params as { serverId: string }
const parsed = ipsecCertDeleteRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const q = req.query as { force?: string }
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 name = parsed.data.name
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
const usedBy = [
...state.peers.filter((p) => (p.certificate ?? "").trim() === name).map((p) => `peer ${(p.name ?? "").trim()}`),
...state.identities
.filter((i) => (i.certificate ?? "").trim() === name || (i["remote-certificate"] ?? "").trim() === name)
.map((i) => `identity ${identityDisplayName(i, state.certs)}`),
]
if (usedBy.length > 0 && q.force !== "true") {
return reply.status(409).send({
error: `Сертификат «${name}» используется: ${usedBy.join(", ")}.`,
usedBy,
})
}
await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`)
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.post("/ipsec/users/:serverId/:rosId/cert", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
+26
View File
@@ -5,6 +5,7 @@ import {
buildSswanConfig,
clientCertName,
findFreePoolIp,
identityDisplayName,
ipsecManagedComment,
ipsecSlug,
ipsecUserComment,
@@ -52,6 +53,31 @@ import {
assert.equal(findFreePoolIp("мусор", []), null)
}
{
// managed: user= из comment
assert.equal(identityDisplayName({ comment: ipsecUserComment("alice"), ".id": "*1" }, []), "alice")
// существующий RouterOS identity: сырой comment
assert.equal(identityDisplayName({ comment: "home office", ".id": "*2" }, []), "home office")
// remote-id
assert.equal(identityDisplayName({ "remote-id": "[email protected]", ".id": "*3" }, []), "[email protected]")
// CN сертификата по remote-certificate
assert.equal(
identityDisplayName(
{ "remote-certificate": "ipsec-user-bob", ".id": "*4" },
[{ name: "ipsec-user-bob", "common-name": "bob" }],
),
"bob",
)
// fallback: peer#shortId
assert.equal(
identityDisplayName({ peer: "ikev2-srv", ".id": "*AB12CD34" }, []),
"ikev2-srv#AB12CD",
)
// fallback: rosId
assert.equal(identityDisplayName({ ".id": "*FF" }, []), "FF")
assert.equal(identityDisplayName({}, []), "identity")
}
{
const sswan = buildSswanConfig({
name: "IKEv2 vpn.example.com",
+29
View File
@@ -24,6 +24,35 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin
return m?.[1] ?? null
}
type IdentityLike = {
".id"?: string
comment?: string
"remote-id"?: string
"remote-certificate"?: string
peer?: string
}
type CertLike = { name?: string; "common-name"?: string }
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
const comment = (i.comment ?? "").trim()
const managedName = parseIpsecUserComment(comment)
if (managedName) return managedName
if (comment) return comment
const remoteId = (i["remote-id"] ?? "").trim()
if (remoteId) return remoteId
const remoteCert = (i["remote-certificate"] ?? "").trim()
if (remoteCert) {
const cn = String(certs.find((c) => String(c.name ?? "").trim() === remoteCert)?.["common-name"] ?? "").trim()
return cn || remoteCert
}
const rosId = String(i[".id"] ?? "").replace(/^\*/, "")
const peer = (i.peer ?? "").trim()
if (peer) return `${peer}#${rosId.slice(0, 6) || "identity"}`
return rosId || "identity"
}
// ── naming-конвенции управляемых объектов RouterOS ──────────────────────────
export const IPSEC_CA_CERT = "ipsec-ca"
+21 -20
View File
@@ -16,8 +16,8 @@ import {
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
clientCertName,
identityDisplayName,
isIpsecManagedComment,
parseIpsecUserComment,
} from "./ipsec-config.js"
import {
canonicalIpsecSnapshot,
@@ -111,6 +111,8 @@ export function mapCertificate(c: RosCertRow): 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-")
return {
name,
@@ -120,7 +122,7 @@ 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 ? "server" : isUser ? "client" : "other",
role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other",
managed: isCa && name === IPSEC_CA_CERT
|| name === IPSEC_SERVER_CERT
|| isUser
@@ -202,14 +204,9 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/** Клиенты = managed-идентичности (созданы менеджером). */
/** Клиенты = все identity роутера (managed + существующие RouterOS). */
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const certByCommonName = new Map<string, RosCertRow>()
for (const c of state.certs) {
const cn = String(c["common-name"] ?? "").trim()
if (cn) certByCommonName.set(cn, c)
}
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
const activeByRemote = new Map<string, RosIpsecActivePeer>()
for (const a of state.active) {
@@ -218,10 +215,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
}
return state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i): IpsecClientDto => {
const comment = i.comment ?? ""
const name = parseIpsecUserComment(comment) ?? (comment || "client")
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
@@ -229,8 +226,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
: ""
const mcName = (i["mode-config"] ?? "").trim()
const mc = mcByName.get(mcName)
const staticIp = mc && (mc.name ?? "").startsWith("mc-ipsec-")
? (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "") || undefined
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)
@@ -252,7 +249,7 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
activeSince: active?.established || undefined,
disabled: asBool(i.disabled),
comment: comment || undefined,
managed: true,
managed,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
@@ -262,6 +259,8 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
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-")),
)
@@ -270,7 +269,9 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
?? 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 natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
return {
serverId: String(server.id),
@@ -278,7 +279,8 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
serverCountry: server.country ?? undefined,
initialized: Boolean(managedPeer && caCert && serverCert),
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
peer: managedPeer ? mapPeer(server, managedPeer) : undefined,
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
peers: state.peers.map((p) => mapPeer(server, p)),
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert: caCert ? mapCertificate(caCert) : undefined,
@@ -286,15 +288,14 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: state.certs
.filter((c) => {
const n = String(c.name ?? "")
return n === IPSEC_CA_CERT || n === IPSEC_SERVER_CERT || n.startsWith("ipsec-user-")
})
.map(mapCertificate),
certs: state.certs.map(mapCertificate),
}
}
/**
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
*/
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
const state = await fetchIpsecState(server)
return canonicalIpsecSnapshot({
+8
View File
@@ -206,6 +206,14 @@ export async function deleteIdentity(client: MikrotikClient, rosId: string): Pro
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
}
export async function patchPeer(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body)
}
export async function deletePeer(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`)
}
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))