Files
MikrotikManager/backend/src/routes/ipsec.ts
T
DenozordecandCursor 569b6be2b4
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
fix(ipsec): не блокироваться на пароле .p12 и подсказать политику sensitive
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 00:15:05 +07:00

755 lines
33 KiB
TypeScript

import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply } from "fastify"
import {
IPSEC_MIN_PASSPHRASE,
ipsecCertDeleteRequestSchema,
ipsecCertExportByNameRequestSchema,
ipsecCertExportRequestSchema,
ipsecInitRequestSchema,
ipsecPeerPatchSchema,
ipsecUserCreateRequestSchema,
ipsecUserPatchSchema,
type IpsecCertBundle,
type IpsecClientDto,
} from "@mmapp/contracts/ipsec"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
buildClientInstructions,
buildSswanConfig,
clientCertName,
identityDisplayName,
ipsecManagedComment,
ipsecUserComment,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName,
} from "../services/ipsec-config.js"
import {
ensureIpsecPeer,
ensureIpsecPool,
ensureIpsecProfile,
ensureIpsecProposal,
ensureNatRule,
ensurePolicyTemplate,
ensureSharedModeConfig,
putUserModeConfig,
deleteUserModeConfig,
putIdentity,
patchIdentity,
deleteIdentity,
patchPeer,
deletePeer,
ensureSharedIke2Identity,
listByPath,
} from "../services/ipsec-ros.js"
import {
ensureCaCertificate,
ensureServerCertificate,
exportCertificateP12ByName,
findCertificate,
issueClientCertificate,
} from "../services/ipsec-ca.js"
import {
captureIpsecSnapshot,
fetchIpsecRestoreState,
fetchIpsecState,
getEnabledIpsecServerById,
listIpsec,
mapClients,
mapServerSummary,
} from "../services/ipsec-live.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import { randomBytes } from "node:crypto"
import { z } from "zod"
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
function generatePassphrase(): string {
return randomBytes(9).toString("base64url")
}
function serverIdParam(v: string): string {
return decodeURIComponent(v)
}
function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function errReply(reply: FastifyReply, e: unknown) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
/**
* 400 по невалидному телу. Для короткого пароля .p12 — понятный текст вместо generic-сообщения:
* RouterOS отклоняет `export-passphrase` короче 8 символов.
*/
function badBodyReply(reply: FastifyReply, error: z.ZodError) {
const shortPassphrase = error.issues.some(
(issue) => issue.path[0] === "passphrase" && issue.code === "too_small",
)
if (shortPassphrase) {
return reply.status(400).send({
error: `Пароль архива .p12 должен быть не короче ${IPSEC_MIN_PASSPHRASE} символов (требование RouterOS)`,
})
}
return reply.status(400).send({ error: "Некорректное тело запроса", details: error.flatten() })
}
async function recordIpsec(
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "ipsec",
source,
capture: () => captureIpsecSnapshot(server),
})
}
/** Существующие статические IP клиентов (персональные mode-config). */
function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "address-prefix"?: string }>): string[] {
return modeConfigs
.filter((m) => (m.name ?? "").startsWith("mc-ipsec-"))
.map((m) => String(m.address ?? m["address-prefix"] ?? "").replace(/\/\d+$/, "").trim())
.filter(Boolean)
}
async function buildCertBundle(
client: MikrotikClient,
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
): Promise<IpsecCertBundle> {
const certName = args.certName?.trim() || clientCertName(args.userName)
const { fileName, content, passphrase } = await exportCertificateP12ByName(client, certName, args.passphrase)
const p12B64 = content.toString("base64")
return {
user: args.userName,
serverEndpoint: args.serverEndpoint,
filename: fileName,
contentB64: p12B64,
mime: "application/x-pkcs12",
passphrase,
sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({
name: `IKEv2 ${args.serverEndpoint}`,
serverEndpoint: args.serverEndpoint,
serverId: args.serverEndpoint,
p12B64,
}),
instructions: buildClientInstructions({
userName: args.userName,
serverEndpoint: args.serverEndpoint,
p12Filename: fileName,
passphrase,
dns: args.dns,
}),
}
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/ipsec", async (req, reply) => {
const q = req.query as { serverId?: string }
const result = await listIpsec({ serverId: q.serverId })
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledIpsecServerById(sid)
if (server) await recordIpsec(server, "observed")
}
return reply.send(result)
})
app.post("/ipsec/server/init", async (req, reply) => {
const parsed = ipsecInitRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const ranges = poolRangesFromCidr(body.poolCidr)
if (!ranges) {
return reply.status(400).send({ error: `Некорректная подсеть пула: ${body.poolCidr}` })
}
const server = await getEnabledIpsecServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const caCert = await ensureCaCertificate(client, body.caDaysValid)
const serverCert = await ensureServerCertificate(client, {
serverEndpoint: body.serverEndpoint,
caCertName: caCert,
daysValid: body.serverDaysValid,
})
const comment = ipsecManagedComment("IKEv2 road-warrior")
await ensureIpsecProfile(client, IPSEC_COMMON_NAME, comment)
await ensureIpsecProposal(client, IPSEC_COMMON_NAME, comment)
await ensureIpsecPool(client, IPSEC_COMMON_NAME, ranges, comment)
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
if (body.createNatRule) {
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
}
await recordIpsec(server, "apply")
const state = await fetchIpsecState(server)
return reply.status(201).send(mapServerSummary(state, mapClients(state)))
} catch (e) {
return errReply(reply, e)
}
})
app.delete("/ipsec/server/:serverId", async (req, reply) => {
const { serverId } = req.params as { serverId: string }
const q = req.query as { removeCertificates?: string }
const removeCertificates = q.removeCertificates !== "false"
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) {
const rows = await listByPath(client, path)
for (const row of rows) {
if (!row[".id"]) continue
if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) {
await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
}
}
}
const nat = await listByPath(client, "/ip/firewall/nat")
for (const row of nat) {
if (row[".id"] && isIpsecManagedComment(row.comment)) {
await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
}
}
// profile/proposal не критично оставлять; сертификаты — по флагу
if (removeCertificates) {
const certs = await client.getCertificates()
for (const c of certs) {
const name = String(c.name ?? "")
if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) {
await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined)
}
}
}
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e)
}
})
app.post("/ipsec/users", async (req, reply) => {
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return badBodyReply(reply, parsed.error)
}
const body = parsed.data
const server = await getEnabledIpsecServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
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)
?? 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 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(serverCertRow?.["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)" })
}
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()
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} уже назначен другому клиенту` })
}
modeConfig = userModeConfigName(body.name)
await putUserModeConfig(client, modeConfig, ip, ipsecManagedComment(`клиент ${body.name.trim()}`))
}
let certName: string | undefined
if (body.authMethod === "certificate") {
const issued = await issueClientCertificate(client, {
userName: body.name,
caCertName: caName!,
daysValid: body.daysValid ?? 1825,
})
if (issued.existed) {
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` })
}
certName = issued.certName
}
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) {
const passphrase = body.passphrase?.trim() || generatePassphrase()
bundle = await buildCertBundle(client, {
userName: body.name,
serverEndpoint,
passphrase,
dns,
})
}
await recordIpsec(server, "apply")
const fresh = await fetchIpsecState(server)
const clients = mapClients(fresh)
const created: IpsecClientDto | undefined = clients.find(
(c) => c.name === body.name.trim(),
) ?? clients.find((c) => c.certificateName === certName)
return reply.status(201).send({ client: created ?? null, bundle })
} catch (e) {
return errReply(reply, e)
}
})
app.patch("/ipsec/users/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = ipsecUserPatchSchema.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 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) {
if (identity) {
// managed: user=<name>; существующий 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 (identity && d.staticIp !== undefined) {
const currentMc = (identity["mode-config"] ?? "").trim()
if (d.staticIp == null) {
// вернуть выдачу из пула
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() !== currentMc),
)
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
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,
})
}
if (identity) {
const patch: Record<string, string> = {}
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 })
} catch (e) {
return errReply(reply, e)
}
})
app.delete("/ipsec/users/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const q = req.query as { removeCertificate?: string }
const removeCertificate = q.removeCertificate !== "false"
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 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 ? (identity["mode-config"] ?? "").trim() : ""
const remoteCert = identity
? (identity["remote-certificate"] ?? "").trim()
: String(certRow?.name ?? "").trim()
if (identity) {
await deleteIdentity(client, identity[".id"]!)
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
}
if (removeCertificate) {
// удаляем только клиентский серт (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 })
} catch (e) {
return errReply(reply, e)
}
})
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 })
if (!parsed.success) {
return badBodyReply(reply, parsed.error)
}
const body = 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 identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
const userName = parseIpsecUserComment(identity.comment) ?? ""
if (!userName) return reply.status(400).send({ error: "Не managed-клиент" })
const certName = identity["remote-certificate"]?.trim() || clientCertName(userName)
if (!(await findCertificate(client, certName))) {
return reply.status(404).send({ error: `Сертификат ${certName} не найден на роутере` })
}
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const dns = sharedMc?.["static-dns"]?.trim() || undefined
// export работает по имени сертификата (certName), не по userName
const { fileName, content, passphrase } = await client.exportCertificatePkcs12({
name: certName,
passphrase: body.passphrase,
})
const p12B64 = content.toString("base64")
const bundle: IpsecCertBundle = {
user: userName,
serverEndpoint,
filename: fileName,
contentB64: p12B64,
mime: "application/x-pkcs12",
passphrase,
sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({
name: `IKEv2 ${serverEndpoint}`,
serverEndpoint,
serverId: serverEndpoint,
p12B64,
}),
instructions: buildClientInstructions({
userName,
serverEndpoint,
p12Filename: fileName,
passphrase,
dns,
}),
}
return reply.send(bundle)
} catch (e) {
return errReply(reply, e)
}
})
/** Экспорт .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 badBodyReply(reply, parsed.error)
}
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)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const revisions = await listRevisions(serverId, "ipsec")
return reply.send({ revisions })
})
app.post("/ipsec/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "ipsec",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseIpsecSnapshot(loaded.row.payload)
const state = await fetchIpsecRestoreState(loaded.server)
const ops = planIpsecRestore(desired, {
peers: state.peers,
identities: state.identities,
modeConfigs: state.modeConfigs,
pools: state.pools,
nat: state.nat,
})
await executeRosOps(state.client, ops)
await recordIpsec(loaded.server, "rollback")
const result = await listIpsec({ serverId: String(loaded.server.id) })
return reply.send({ ok: true, ...result })
} catch (e) {
return errReply(reply, e)
}
})
}
export default ipsecRoutes