Files
MikrotikManager/backend/src/routes/ipsec.ts
T

824 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_COMMON_NAME,
IPSEC_POLICY_GROUP,
IPSEC_SERVER_CERT,
buildClientInstructions,
buildSswanConfig,
clientCertName,
identityDisplayName,
ipsecManagedComment,
ipsecUserComment,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
userModeConfigName,
normalizeIpsecRemoteId,
} from "../services/ipsec-config.js"
import {
ensureIpsecPeer,
ensureIpsecPool,
ensureIpsecProfile,
ensureIpsecProposal,
ensureNatRule,
resolveIpsecNatInterface,
IpsecNatInterfaceConflict,
IpsecPolicyGroupConflict,
ipsecPolicyGroupExists,
listByPath,
ensurePolicyTemplate,
ensureSharedModeConfig,
putUserModeConfig,
deleteUserModeConfig,
putIdentity,
patchIdentity,
deleteIdentity,
patchPeer,
deletePeer,
ensureSharedIke2Identity,
} from "../services/ipsec-ros.js"
import {
ensureCaCertificate,
ensureServerCertificate,
exportCertificateP12ByName,
findCertificate,
issueClientCertificate,
} from "../services/ipsec-ca.js"
import {
captureIpsecSnapshot,
fetchIpsecRestoreState,
fetchIpsecState,
getEnabledIpsecServerById,
listIpsec,
mapClients,
resolveIpsecClientTarget,
mapServerSummary,
} from "../services/ipsec-live.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { IpsecRestoreConflict, parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js"
import { parseDbServerId } from "../utils/server-id.js"
import { isIP } from "node:net"
import { randomBytes } from "node:crypto"
import { trackIpsecMutation, type IpsecMutationProgress } from "../services/ipsec-operation.js"
import { z } from "zod"
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, progress?: IpsecMutationProgress) {
if (e instanceof IpsecNatInterfaceConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
if (e instanceof IpsecPolicyGroupConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
if (e instanceof IpsecRestoreConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
if (e instanceof IpsecHistoryError) return reply.status(502).send({ error: e.message, state: "applied", historyRecorded: false })
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
const receipt = progress?.receipt()
const guidance = receipt?.state === "applied" ? "Операция выполнена, но итоговый ответ не получен."
: receipt?.state === "partial" ? "Часть изменений применена." : receipt?.state === "unknown" ? "Результат операции не подтверждён." : ""
return reply.status(502).send({ error: `RouterOS: ${msg}${guidance ? `. ${guidance} Обновите данные перед повтором` : ""}`, ...receipt })
}
/**
* 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() })
}
class IpsecHistoryError extends Error {
constructor() {
super("Операция выполнена на устройстве, но сохранение истории не подтверждено. Обновите данные перед повтором")
}
}
async function recordIpsecDefault(
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
source: ConfigRevisionSource,
record: typeof captureAndAppendRevision,
) {
const result = await record({
serverId: server.id,
section: "ipsec",
source,
capture: () => captureIpsecSnapshot(server),
})
if (!result && source !== "observed") throw new IpsecHistoryError()
}
/** Существующие статические 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 ipsecDefaults = { findServer: getEnabledIpsecServerById, recordRevision: captureAndAppendRevision }
export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAsyncZod {
const getEnabledIpsecServerById = dependencies.findServer
const recordIpsec = (server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>, source: ConfigRevisionSource) => recordIpsecDefault(server, source, dependencies.recordRevision)
return 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 && !result.failures?.some(f => f.serverId === String(sid))) 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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
try {
const natInterface = body.createNatRule ? await resolveIpsecNatInterface(client) : undefined
const groupExists = await ipsecPolicyGroupExists(client)
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)
if (!groupExists) await client.put("/ip/ipsec/policy/group", { name: IPSEC_POLICY_GROUP })
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
await ensureSharedIke2Identity(client, { peerName: IPSEC_COMMON_NAME, serverCertName: serverCert, modeConfigName: IPSEC_COMMON_NAME, comment: ipsecManagedComment("IKEv2 listener"), policyTemplateGroup: IPSEC_POLICY_GROUP })
for (const identity of await listByPath(client, "/ip/ipsec/identity")) {
if (identity.peer === IPSEC_COMMON_NAME && isIpsecManagedComment(identity.comment) && (!identity["policy-template-group"] || identity["policy-template-group"] === "default")) await patchIdentity(client, identity[".id"]!, { "policy-template-group": IPSEC_POLICY_GROUP })
}
if (body.createNatRule) {
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"), natInterface!)
}
progress.complete()
await recordIpsec(server, "apply")
const state = await fetchIpsecState(server)
return reply.status(201).send(mapServerSummary(state, mapClients(state)))
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
try {
await removeManagedIpsecServer(client, removeCertificates)
progress.complete()
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
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]
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, state.identities)
const caName = resolveIke2CaName(state.certs, serverCertRow)
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
return reply.status(400).send({
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
})
}
if (body.peerName && (peer.name ?? "").trim() !== body.peerName.trim()) return reply.status(404).send({ error: "Выбранный peer не найден" })
if (mapClients(state).some(c => c.name === body.name.trim()) || (body.authMethod === "certificate" && state.certs.some(c => c.name === clientCertName(body.name)))) return reply.status(409).send({ error: "Клиент уже существует" })
const peerName = (peer.name ?? "").trim()
if (body.authMethod === "pre-shared-key" && state.identities.some(i => i.peer === peerName && i["auth-method"] === "pre-shared-key" && i["remote-id"] === normalizeIpsecRemoteId(body.remoteId ?? body.name))) return reply.status(409).send({ error: "Remote ID уже используется клиентом этого peer" })
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 (isIP(ip) !== 4) 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) {
const receipt = progress.receipt()
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует${receipt ? ". Часть изменений применена; обновите данные перед повтором" : ""}`, ...receipt })
}
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,
})
}
progress.complete()
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, progress)
}
})
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
if ("staticIp" in d && d.staticIp && isIP(d.staticIp.trim()) !== 4) return reply.status(400).send({ error: "Некорректный статический IPv4 адрес" })
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
try {
const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId)
const target = resolveIpsecClientTarget(state, targetRosId)
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
let { identity, certRow } = target
if (certRow) {
identity = state.identities.find(i => i["remote-certificate"] === certRow?.name)
if (identity) certRow = undefined
}
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, state.identities)
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
const normalizedRemoteId = d.remoteId === undefined ? undefined : normalizeIpsecRemoteId(d.remoteId)
if (d.name && newName !== oldName && mapClients(state).some(c => c.name === newName && (identity ? c.rosId !== `identity:${identity[".id"]}` : c.certName !== certRow?.name))) return reply.status(409).send({ error: "Имя уже используется другим клиентом" })
if (identity && normalizedRemoteId !== undefined && state.identities.some(i => i[".id"] !== identity[".id"] && i.peer === identity.peer && i["auth-method"] === "pre-shared-key" && i["remote-id"] === normalizedRemoteId)) return reply.status(409).send({ error: "Remote ID уже используется клиентом этого peer" })
// Validate conflicts before changing even the display name.
if (d.staticIp) {
const currentMc = (identity?.["mode-config"] ?? "").trim()
if (takenStaticIps(state.modeConfigs.filter(m => !currentMc || m.name !== currentMc)).includes(d.staticIp.trim())) return reply.status(409).send({ error: "IP уже назначен другому клиенту" })
}
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(),
})
}
// Preserve the signed certificate CN/key/name; keep the editable display name in an identity.
if (certRow && !d.staticIp) {
const peer = ike2Peers.find(p => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
if (!peer || !serverCertRow) return reply.status(400).send({ error: "Нет IKEv2 peer или серверного сертификата" })
await putIdentity(client, { peerName: peer.name ?? "", modeConfig: sharedMcName, comment: ipsecUserComment(d.name), authMethod: "certificate", certificate: serverCertRow.name, remoteCertificate: certRow.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 (isIP(ip) !== 4) 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 })
if (currentMc && currentMc !== sharedMcName && currentMc !== name) await deleteUserModeConfig(client, currentMc)
}
} else if (!identity && certRow && d.staticIp) {
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
const ip = d.staticIp.trim()
if (isIP(ip) !== 4) 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"] = normalizedRemoteId!
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)
}
progress.complete()
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
try {
const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId)
const target = resolveIpsecClientTarget(state, targetRosId)
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
const identity = target.identity ?? (target.certRow ? state.identities.find(i => i["remote-certificate"] === target.certRow?.name) : undefined)
const certRow = identity ? undefined : target.certRow
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
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()
// Refuse before deleting the identity: a shared certificate must survive.
// A display name does not establish ownership of a certificate.
const deletionCertName = remoteCert
if (removeCertificate && deletionCertName && (
state.identities.some(i => i[".id"] !== identity?.[".id"] && ((i["remote-certificate"] ?? "").trim() === deletionCertName || (i.certificate ?? "").trim() === deletionCertName))
|| state.peers.some(p => (p.certificate ?? "").trim() === deletionCertName)
)) return reply.status(409).send({ error: "Сертификат используется другой identity или peer. Удалите только выбранную identity, сохранив сертификат" })
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, state.identities))
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers, state.identities)?.name ?? "").trim()
const certName = remoteCert
if (certName && certName !== caName && certName !== serverCertName) {
await client.removeCertificate(certName)
}
}
progress.complete()
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
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) return reply.status(400).send({ error: "Сертификат задаётся в IPsec identity, а не в peer" })
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)
progress.complete()
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
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))
progress.complete()
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
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"])}`)
progress.complete()
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return errReply(reply, e, progress)
}
})
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
try {
const state = await fetchIpsecState(server)
const target = resolveIpsecClientTarget(state, rosIdParam(rosId))
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
const identity = target.identity
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, progress)
}
})
/** Экспорт .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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
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, progress)
}
})
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 })
let progress: IpsecMutationProgress | undefined
try {
const desired = parseIpsecSnapshot(loaded.row.payload)
const state = await fetchIpsecRestoreState(loaded.server)
const missingCertificates = desired.identities.flatMap(i => [i.certificate, i.remoteCertificate]).filter(n => n && !state.certificateNames.includes(n))
if (missingCertificates.length) return reply.status(409).send({ error: "На устройстве отсутствуют сертификаты ревизии. Откат не восстанавливает ключи PKI; изменений не было" })
const profiles = await state.client.get<unknown>("/ip/ipsec/profile")
if (!Array.isArray(profiles) || profiles.some(p => !p || typeof p.name !== "string")) throw new Error("Некорректный список IPsec profiles; откат не начат")
if (desired.peers.some(p => p.profile && !profiles.some(live => live.name === p.profile))) return reply.status(409).send({ error: "На устройстве отсутствует profile ревизии. Восстановите profile перед откатом; изменений не было" })
if (desired.identities.some(i => i.policyTemplateGroup)) {
const groups = await state.client.get<unknown>("/ip/ipsec/policy/group")
if (!Array.isArray(groups) || groups.some(g => !g || typeof g.name !== "string")) throw new Error("Некорректный список групп политик; откат не начат")
if (desired.identities.some(i => i.policyTemplateGroup && !groups.some(g => g.name === i.policyTemplateGroup))) return reply.status(409).send({ error: "Группа политик ревизии отсутствует. Восстановите её перед откатом; изменений не было" })
}
const ops = planIpsecRestore(desired, {
peers: state.peers,
identities: state.identities,
modeConfigs: state.modeConfigs,
pools: state.pools,
nat: state.nat,
policies: state.policies,
})
progress = trackIpsecMutation(state.client)
await executeRosOps(progress.client, ops)
progress.complete()
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, progress)
}
})
}
}
export default createIpsecRoutes()