fix(ipsec): подтвердить сертификатный клиент и сообщать сбой истории
This commit is contained in:
1 parent
b9a8615b8b
commit
5d9ef9dcf3
11 files changed
+176
-12
No files matched your search
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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`)
|
||||
Reference in new issue
Block a user