fix(ipsec): проверять CRL-цепочку до удаления identity

This commit is contained in:
Denozordec committed 2026-10-11 02:56:09 +07:00
1 parent a31a65fbe6
commit 7867914cbc
9 files changed
+349 -14

No files matched your search

+13 -1
View File
@@ -6,9 +6,11 @@ import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { fetchIpsecState } from "../services/ipsec-live.js"
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
const original = MikrotikClient.fromServer
let pkiMode = ""
let writes = 0, records = 0, fault = "", certificateFixture = false, removedCertificates = 0, exports = 0, exportedName = ""
const identities: Record<string,string>[] = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }]
MikrotikClient.fromServer = () => ({ get: async (p: string) => certificateFixture && p === "/certificate" ? [{ ".id": "*CERT", name: "ipsec-user-shared", "common-name": "shared", "key-usage": "tls-client" }] : p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], getCertificates: async () => certificateFixture ? [{name:"ipsec-user-shared"}] : [], exportCertificatePkcs12: async (args: {name:string}) => {exports++;exportedName=args.name;return {fileName:args.name+".p12",content:Buffer.from("fixture-only-archive"),passphrase:"fixture-only-pass"}}, removeCertificate: async () => { writes++; removedCertificates++ }, put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record<string,string>) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient
function certificateRows(): Record<string,string>[] {const leaf:Record<string,string>={".id":"*CERT",name:"ipsec-user-shared","common-name":"shared","key-usage":"tls-client"};if(!pkiMode)return [leaf];leaf.ca="test-crl-ca";if(pkiMode==="issued")leaf.issued="true";if(pkiMode==="revoked")leaf.revoked="true";if(pkiMode==="imported")leaf.issued="false";return [leaf,{".id":"*CA",name:"test-crl-ca","common-name":"test-crl-ca","key-usage":"key-cert-sign,crl-sign",crl:"true"}];}
MikrotikClient.fromServer = () => ({ get: async (p: string) => certificateFixture && p === "/certificate" ? certificateRows() : p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], getCertificates: async () => certificateFixture ? [{name:"ipsec-user-shared"}] : [], exportCertificatePkcs12: async (args: {name:string}) => {exports++;exportedName=args.name;return {fileName:args.name+".p12",content:Buffer.from("fixture-only-archive"),passphrase:"fixture-only-pass"}}, removeCertificate: async () => { writes++; removedCertificates++ }, put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record<string,string>) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient
const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
await app.register(createIpsecRoutes({ findServer: async () => server, recordRevision: async () => { records++; return {created:false,revision:{} as never} } }), { prefix: "/api" })
const patch = (payload: Record<string, unknown>) => app.inject({ method: "PATCH", url: "/api/ipsec/users/1/identity:*I", payload })
@@ -39,6 +41,16 @@ for(const method of ["PATCH","DELETE"] as const){writes=records=0;const r=await
writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*FIRST"});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.equal(records,0);
writes=records=0;r=await app.inject({method:"PATCH",url:"/api/ipsec/users/1/identity:*FIRST",payload:{name:"Selected"}});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Selected");assert.equal(identities[1]!.comment,"MikrotikManager:ipsec user=Shared1");
writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*FIRST?removeCertificate=false"});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(records,1);
// A CRL issuer protects both issued and already-revoked native certificates.
for(const mode of ["issued","revoked"]) {
pkiMode=mode;identities.splice(0,identities.length,{".id":"*CRL-BOUND",peer:"ike","auth-method":"digital-signature","remote-certificate":"ipsec-user-shared",comment:"MikrotikManager:ipsec user=CRL client"});
for(const url of ["/api/ipsec/users/1/identity:*CRL-BOUND","/api/ipsec/users/1/cert:*CERT"]) {writes=records=removedCertificates=0;const r=await app.inject({method:"DELETE",url});assert.equal(r.statusCode,409);assert.match(r.json().error,/CA с CRL/);assert.equal(writes,0);assert.equal(records,0);}
for(const force of ["","?force=true"]) {writes=records=0;const r=await app.inject({method:"DELETE",url:"/api/ipsec/certs/1"+force,payload:{name:"ipsec-user-shared"}});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.equal(records,0);}
writes=records=removedCertificates=0;const r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*CRL-BOUND?removeCertificate=false"});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(removedCertificates,0);assert.equal(records,1);
}
for(const force of ["","?force=true"]) {writes=records=0;const r=await app.inject({method:"DELETE",url:"/api/ipsec/certs/1"+force,payload:{name:"test-crl-ca"}});assert.equal(r.statusCode,409);assert.match(r.json().error,/всю цепочку/);assert.equal(writes,0);assert.equal(records,0);}
pkiMode="imported";identities.splice(0,identities.length);writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/certs/1",payload:{name:"ipsec-user-shared"}});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(records,1);pkiMode="";
} finally { await app.close(); MikrotikClient.fromServer=original }
console.log("IPsec HTTP mutations: no-write conflicts, acknowledged partial failure, applied-but-lost ack and remote ID normalization")
+20
View File
@@ -101,6 +101,21 @@ function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function certificateDeleteConflict(state: Awaited<ReturnType<typeof fetchIpsecState>>, name: string): string | undefined {
const cert = state.certs.find(c => (c.name ?? "").trim() === name)
if (!cert) return undefined
// Removing a CA cascades to its issued children, including foreign clients.
if (state.certs.some(c => (c.ca ?? "").trim() === name)) {
return "CA содержит выданные сертификаты. Его удаление затронет всю цепочку; отдельное удаление CA здесь недоступно"
}
const issuer = state.certs.find(c => (c.name ?? "").trim() === (cert.ca ?? "").trim())
const revoked = cert.revoked && !["false", "no", "0"].includes(cert.revoked)
if (issuer?.crl === "true" && (cert.issued === "true" || revoked)) {
return "Сертификат выдан CA с CRL и не удаляется отдельно в RouterOS. Удалите только identity, сохранив сертификат. Отзыв выполняется отдельно"
}
return undefined
}
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}. Изменений не было` })
@@ -549,6 +564,9 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|| state.peers.some(p => (p.certificate ?? "").trim() === deletionCertName)
)) return reply.status(409).send({ error: "Сертификат используется другой identity или peer. Удалите только выбранную identity, сохранив сертификат" })
const certificateConflict = removeCertificate && deletionCertName ? certificateDeleteConflict(state, deletionCertName) : undefined
if (certificateConflict) return reply.status(409).send({ error: certificateConflict })
if (identity) {
await deleteIdentity(client, identity[".id"]!)
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
@@ -649,6 +667,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
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 certificateConflict = certificateDeleteConflict(state, name)
if (certificateConflict) return reply.status(409).send({ error: certificateConflict })
const usedBy = [
...state.peers.filter((p) => (p.certificate ?? "").trim() === name).map((p) => `peer ${(p.name ?? "").trim()}`),
...state.identities