fix(ipsec): подтвердить сертификатный клиент и сообщать сбой истории

This commit is contained in:
Denozordec committed 2026-10-09 15:32:10 +07:00
1 parent b9a8615b8b
commit 5d9ef9dcf3
11 files changed
+176 -12

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createIpsecRoutes } from "./ipsec.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { fetchIpsecState } from "../services/ipsec-live.js"
const original = MikrotikClient.fromServer
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
let patches = 0, recordings = 0, nativeDisabled = "false", rejectWrite = false
MikrotikClient.fromServer = () => ({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2", disabled: nativeDisabled }] : [], patch: async (_path: string, body: Record<string, string>) => { if (rejectWrite) throw Error("write rejected"); patches++; nativeDisabled = body.disabled! } }) as unknown as MikrotikClient
try {
for (const historySaved of [false, true]) {
patches = recordings = 0; nativeDisabled = "false"
const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
await app.register(createIpsecRoutes({ findServer: async () => server, recordRevision: async () => { recordings++; return historySaved ? { created: false, revision: {} as never } : null } }), { prefix: "/api" })
const response = await app.inject({ method: "PATCH", url: "/api/ipsec/peers/1/*P", payload: { disabled: true } })
assert.equal(response.statusCode, historySaved ? 200 : 502)
assert.equal(patches, 1); assert.equal(recordings, 1); assert.equal(nativeDisabled, "yes")
if (!historySaved) { assert.equal(response.json().state, "applied"); assert.equal(response.json().historyRecorded, false); assert.match(response.json().error, /Обновите данные перед повтором/) }
else assert.deepEqual(response.json(), { ok: true })
rejectWrite = true; const rejected = await app.inject({ method: "PATCH", url: "/api/ipsec/peers/1/*P", payload: { disabled: false } }); rejectWrite = false
assert.equal(rejected.statusCode, 502); assert.equal(rejected.json().state, undefined); assert.equal(recordings, 1)
await app.close()
}
} finally { MikrotikClient.fromServer = original }
console.log("IPsec HTTP: confirmed mutation + failed/saved history, rejected write does not claim applied")
+20 -4
View File
@@ -94,6 +94,7 @@ function rosIdParam(v: string): string {
}
function errReply(reply: FastifyReply, e: unknown) {
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)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
@@ -114,16 +115,24 @@ function badBodyReply(reply: FastifyReply, error: z.ZodError) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: error.flatten() })
}
async function recordIpsec(
class IpsecHistoryError extends Error {
constructor() {
super("Операция выполнена на устройстве, но сохранение истории не подтверждено. Обновите данные перед повтором")
}
}
async function recordIpsecDefault(
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
source: ConfigRevisionSource,
record: typeof captureAndAppendRevision,
) {
await captureAndAppendRevision({
const result = await record({
serverId: server.id,
section: "ipsec",
source,
capture: () => captureIpsecSnapshot(server),
})
if (!result && source !== "observed") throw new IpsecHistoryError()
}
/** Существующие статические IP клиентов (персональные mode-config). */
@@ -167,7 +176,12 @@ async function buildCertBundle(
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
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 })
@@ -735,4 +749,6 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
})
}
export default ipsecRoutes
}
export default createIpsecRoutes()
+4 -1
View File
@@ -280,7 +280,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const comment = identity?.comment ?? ""
const mcName = (identity?.["mode-config"] ?? "").trim()
const cn = String(cert["common-name"] ?? "").trim()
const active = (cn ? findActive(cn) : undefined)
// Our issued certificates have a single CN subject; match the whole DN, never a CN substring.
const singleCn = cn && !/[,+=<>;\\"\u0000]/.test(cn) && state.certs.filter(c => c["common-name"] === cn).length === 1
? activeByRemote.get(`CN=${cn}`) : undefined
const active = (cn ? findActive(cn) : undefined) ?? singleCn
?? (identity?.["remote-id"] ? findActive(String(identity["remote-id"]).trim()) : undefined)
out.push({
id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
+7
View File
@@ -92,4 +92,11 @@ for (const prefix of ["fqdn", "user-fqdn", "address", "unknown"]) {
sessionState.active = [{ ".id": "*ACTIVE", id: "alice", state: "established" }]
assert.equal(mapClients(sessionState)[0]?.online, prefix !== "unknown"); checks++
}
state.active = [{ ".id": "*CERTACTIVE", id: "CN=Original CN", state: "established", "dynamic-address": "198.18.79.250", uptime: "1s" }]
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, true); checks++
state.active[0]!.id = "CN=Original CN,O=Other"
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
state.active[0]!.id = "CN=Original CN"
state.certs.push({ ".id": "*DUPCN", name: "duplicate-cn", "common-name": "Original CN", ca: "ca", "key-usage": "tls-client" })
assert.equal(mapClients(state).find(c => c.certName === "client")?.online, false); checks++
console.log(`ipsec-state: ${checks} checks passed`)