116 lines
15 KiB
TypeScript
116 lines
15 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { randomUUID } from "node:crypto"
|
|
import { createServer } from "node:http"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import Fastify from "fastify"
|
|
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
|
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
|
|
import { pool } from "../../db/index.js"
|
|
import type { AuthUser } from "../../lib/permissions.js"
|
|
import ipsecRoutes, { createIpsecRoutes } from "../../routes/ipsec.js"
|
|
import { getEnabledIpsecServerById } from "../../services/ipsec-live.js"
|
|
import { ipsecPeerDeletePaths } from "../../services/ipsec-peer-delete.js"
|
|
import { createMcpConnection, authenticateMcpToken, setConnectionStatus } from "./store.js"
|
|
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
|
|
|
|
/** Explicit SQL + loopback HTTP fixture only. Never calls a real RouterOS or exports a key. */
|
|
export async function acceptIpsecUserDeletion(actor: AuthUser) {
|
|
const serverId = 194, base: Record<string, Record<string, string>[]> = Object.fromEntries(ipsecPeerDeletePaths.map(path => [path, []]))
|
|
base["/ip/ipsec/peer"] = [{ ".id": "*p", name: "ike", "exchange-mode": "ike2", disabled: "true", opaque: "NEVER_USER_PEER" }]
|
|
base["/ip/ipsec/identity"] = [{ ".id": "*i", peer: "ike", "auth-method": "digital-signature", "remote-certificate": "ipsec-user-alice", certificate: "server", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice", disabled: "true", secret: "NEVER_USER_SECRET" }, { ".id": "*l", peer: "ike", "auth-method": "digital-signature", certificate: "server", "mode-config": "ipsec-vpn", disabled: "true", comment: "MikrotikManager:ipsec IKEv2 listener" }]
|
|
base["/ip/ipsec/mode-config"] = [{ ".id": "*m", name: "mc-ipsec-alice", address: "198.18.0.1", responder: "true" }, { ".id": "*s", name: "ipsec-vpn", "address-pool": "vpn-pool" }]
|
|
base["/certificate"] = [{ ".id": "*c", name: "ipsec-user-alice", "common-name": "Alice", "key-usage": "tls-client", ca: "ca", opaque: "NEVER_USER_CERT" }, { ".id": "*a", name: "ca", "key-usage": "key-cert-sign", authority: "true" }, { ".id": "*v", name: "server", "key-usage": "tls-server", "common-name": "vpn.fixture", ca: "ca" }]
|
|
let catalogs = structuredClone(base), mode = "normal", failAt = 1, dispatched = 0, deletes = 0, reads = 0, connectionId: string | undefined
|
|
let duringRead: (() => Promise<unknown>) | undefined, afterDelete: (() => Promise<unknown>) | undefined
|
|
const router = createServer((req, res) => {
|
|
res.setHeader("content-type", "application/json"); const path = (req.url ?? "").replace(/^\/rest/, "")
|
|
if (req.method === "GET") {
|
|
reads++
|
|
if (duringRead) { const hook = duringRead; duringRead = undefined; void hook().then(() => res.end(JSON.stringify(catalogs[path] ?? [])), () => { res.statusCode = 503; res.end("{}") }); return }
|
|
if (mode === "provider-error") { res.statusCode = 400; res.end('{"message":"NEVER_USER_PROVIDER"}'); return }
|
|
res.end(JSON.stringify(catalogs[path] ?? [])); return
|
|
}
|
|
if (req.method === "DELETE") {
|
|
const split = path.lastIndexOf("/"), collection = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1))
|
|
if (!["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/certificate"].includes(collection) || !catalogs[collection].some(row => row[".id"] === id)) { res.statusCode = 404; res.end("{}"); return }
|
|
deletes++; dispatched++
|
|
if (!(mode === "ignored" && dispatched === failAt)) catalogs[collection] = catalogs[collection].filter(row => row[".id"] !== id)
|
|
if (mode === "collateral" && dispatched === failAt) catalogs["/ip/ipsec/peer"][0].opaque = "changed"
|
|
if (mode === "lost" && dispatched === failAt) { req.socket.destroy(); return }
|
|
if (afterDelete) { const hook = afterDelete; afterDelete = undefined; void hook().then(() => res.end("{}"), () => { res.statusCode = 503; res.end("{}") }); return }
|
|
res.end("{}"); return
|
|
}
|
|
res.statusCode = 400; res.end("{}")
|
|
})
|
|
await new Promise<void>(resolve => router.listen(0, "127.0.0.1", resolve))
|
|
const rest = Fastify(); rest.setValidatorCompiler(validatorCompiler); rest.setSerializerCompiler(serializerCompiler)
|
|
const reset = () => { mode = "normal"; dispatched = 0; catalogs = structuredClone(base) }
|
|
const wait = async (id: string) => { const deadline = Date.now() + 140000; do { const state = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [id])).rows[0].state; if (!["pending", "running"].includes(state)) return state; await delay(25) } while (Date.now() < deadline); throw Error("IPsec user operation live; never replay") }
|
|
try {
|
|
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'ipsec-user-delete-proof','127.0.0.1',$2,false,'NEVER_USER_ENDPOINT')", [serverId, (router.address() as { port: number }).port])
|
|
await rest.register(ipsecRoutes, { prefix: "/api" })
|
|
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "IPsec user deletion", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write", "mm:network:read"], scope: { serverIds: [serverId], operationIds: ["network.ipsec.users.delete", "operations.reconcile"], allowGlobal: false }, limits: { callsPerMinute: 120, mutationsPerHour: 100 } }), actor)
|
|
connectionId = access.connection.id; const principal = await authenticateMcpToken(access.token)
|
|
const prepare = async (removeCertificate = true, clientId = "identity:*i") => { const plan = await invokeMcpTool(principal, "mm_ipsec_user_remove_plan", { serverId, clientId, removeCertificate }) as { planId: string; state: string }; assert.equal(plan.state, "awaiting-approval"); assert.ok(!JSON.stringify(plan).includes("NEVER_")); return plan }
|
|
const reconcile = async (operationId: string) => await invokeMcpTool(principal, "mm_operation_reconcile", { operationId }) as { automaticRetry: boolean; results: { matchesDesiredState: boolean; configurationMatchesDesiredState: boolean; configurationPrefixCommands: number | null; verifiedPrefixCommands: number | null }[] }
|
|
const initialReads = reads
|
|
await assert.rejects(invokeMcpTool(principal, "mm_ipsec_user_remove_plan", { serverId: 1, clientId: "identity:*i" }))
|
|
await assert.rejects(invokeMcpTool(principal, "mm_ipsec_user_remove_plan", { serverId, clientId: "Alice" })); assert.equal(reads, initialReads)
|
|
for (const fault of ["normal", "lost", "ignored", "collateral"]) for (const stage of fault === "normal" ? [3] : [1, 2, 3]) {
|
|
reset(); const plan = await prepare(); await approvePlan(plan.planId, actor, true); mode = fault; failAt = stage
|
|
const before = deletes, key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
|
|
assert.equal(await wait(operation.id), fault === "normal" ? "applied" : fault === "lost" ? "unknown" : "partial")
|
|
assert.equal(deletes, before + (fault === "normal" ? 3 : stage)); assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
|
|
const checked = await reconcile(operation.id); assert.equal(checked.automaticRetry, false); assert.equal(checked.results[0].matchesDesiredState, fault === "normal"); assert.equal(checked.results[0].configurationMatchesDesiredState, fault === "normal" || fault === "lost" && stage === 3)
|
|
if (fault === "lost") { assert.equal(checked.results[0].configurationPrefixCommands, stage); assert.equal(checked.results[0].verifiedPrefixCommands, null) }
|
|
assert.equal(deletes, before + (fault === "normal" ? 3 : stage))
|
|
if (fault === "normal") {
|
|
assert.deepEqual(catalogs["/ip/ipsec/identity"], [base["/ip/ipsec/identity"][1]]); assert.deepEqual(catalogs["/certificate"], base["/certificate"].slice(1))
|
|
const saved = (await pool.query("SELECT step_index,intended_state,result FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows[0]
|
|
for (const change of ["history", "intended", "native", "count"]) {
|
|
const wrongInput = structuredClone(saved.intended_state), wrongResult = structuredClone(saved.result)
|
|
if (change === "history") wrongResult.historyRecorded = false
|
|
if (change === "intended") wrongInput.input.removeCertificate = false
|
|
if (change === "native") wrongResult.clientId = "identity:*l"
|
|
if (change === "count") wrongResult.completedCommands = 2
|
|
await pool.query("UPDATE mcp_operation_steps SET intended_state=$3::jsonb,result=$4::jsonb WHERE operation_id=$1 AND step_index=$2", [operation.id, saved.step_index, JSON.stringify(wrongInput), JSON.stringify(wrongResult)])
|
|
const tampered = await reconcile(operation.id); assert.equal(tampered.results[0].configurationMatchesDesiredState, true); assert.equal(tampered.results[0].matchesDesiredState, false)
|
|
await pool.query("UPDATE mcp_operation_steps SET intended_state=$3::jsonb,result=$4::jsonb WHERE operation_id=$1 AND step_index=$2", [operation.id, saved.step_index, JSON.stringify(saved.intended_state), JSON.stringify(saved.result)])
|
|
}
|
|
}
|
|
}
|
|
reset(); catalogs["/ip/ipsec/identity"] = [catalogs["/ip/ipsec/identity"][1]]
|
|
const noop = await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/cert:*c?removeCertificate=false` }); assert.equal(noop.statusCode, 200); assert.equal(dispatched, 0)
|
|
const certOnly = await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/cert:*c` }); assert.equal(certOnly.statusCode, 200); assert.equal(dispatched, 1); assert.deepEqual(catalogs["/certificate"], base["/certificate"].slice(1))
|
|
reset(); catalogs["/certificate"].push({ ".id": "*d", name: "descendant", ca: "ipsec-user-alice" }); await assert.rejects(prepare(), /цепочку/); assert.equal(dispatched, 0)
|
|
reset(); const aba = await prepare(); await approvePlan(aba.planId, actor, true)
|
|
await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=$1", [serverId]); await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=$1", [serverId])
|
|
assert.equal(await wait((await applyPlan(principal, aba.planId, randomUUID())).id), "rejected"); assert.equal(dispatched, 0)
|
|
reset(); const noCert = await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/identity:*i?removeCertificate=false` }); assert.equal(noCert.statusCode, 200); assert.equal(dispatched, 2); assert.deepEqual(catalogs["/certificate"], base["/certificate"])
|
|
reset(); catalogs["/ip/ipsec/identity"][1]["remote-certificate"] = "ipsec-user-alice"; catalogs["/ip/ipsec/identity"][1]["mode-config"] = "mc-ipsec-alice"
|
|
await assert.rejects(prepare(), /другой identity/); assert.equal((await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/identity:*i` })).statusCode, 409); assert.equal(dispatched, 0)
|
|
assert.equal((await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/identity:*i?removeCertificate=false` })).statusCode, 200); assert.equal(dispatched, 1); assert.deepEqual(catalogs["/ip/ipsec/mode-config"], base["/ip/ipsec/mode-config"])
|
|
reset(); catalogs["/certificate"][0][".id"] = "*i"
|
|
assert.equal((await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/*i` })).statusCode, 409); assert.equal(dispatched, 0)
|
|
assert.equal((await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/cert:*i` })).statusCode, 200); assert.equal(dispatched, 3)
|
|
reset(); catalogs["/certificate"][1].crl = "true"; catalogs["/certificate"][0].issued = "true"; await assert.rejects(prepare(), /CRL/); assert.equal(dispatched, 0)
|
|
reset(); const protection = randomUUID(); await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2::jsonb,'integration')", [protection, JSON.stringify({ kind: "certificate", id: "*c", serverId, protection: "deny" })])
|
|
await assert.rejects(prepare()); assert.equal((await rest.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/identity:*i` })).statusCode, 409); assert.equal(dispatched, 0); await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protection])
|
|
reset(); const stale = await prepare(); await approvePlan(stale.planId, actor, true); catalogs["/ip/ipsec/identity"][1].secret = "NEVER_LATE_DRIFT"; assert.equal(await wait((await applyPlan(principal, stale.planId, randomUUID())).id), "rejected"); assert.equal(dispatched, 0)
|
|
reset(); const revoked = await prepare(); await approvePlan(revoked.planId, actor, true); afterDelete = () => setConnectionStatus(access.connection.id, "paused", actor); const stopped = await applyPlan(principal, revoked.planId, randomUUID()); assert.equal(await wait(stopped.id), "unknown"); assert.equal(dispatched, 1); await setConnectionStatus(access.connection.id, "active", actor)
|
|
const staleReads = reads; await assert.rejects(reconcile(stopped.id)); assert.equal(reads, staleReads, "The pre-pause receipt is rejected before native reads after the policy version changes")
|
|
reset(); const freshReadPlan = await prepare(); await approvePlan(freshReadPlan.planId, actor, true)
|
|
const freshReadOperation = await applyPlan(principal, freshReadPlan.planId, randomUUID()); assert.equal(await wait(freshReadOperation.id), "applied")
|
|
const priorReads = reads; duringRead = () => setConnectionStatus(access.connection.id, "paused", actor); await assert.rejects(reconcile(freshReadOperation.id)); assert.equal(reads, priorReads + 1); await setConnectionStatus(access.connection.id, "active", actor)
|
|
reset(); const brokenHistory = Fastify(); brokenHistory.setValidatorCompiler(validatorCompiler); brokenHistory.setSerializerCompiler(serializerCompiler)
|
|
try { await brokenHistory.register(createIpsecRoutes({ findServer: getEnabledIpsecServerById, recordRevision: async () => null }), { prefix: "/api" }); const result = await brokenHistory.inject({ method: "DELETE", url: `/api/ipsec/users/${serverId}/identity:*i` }); assert.equal(result.statusCode, 502); assert.equal(result.json().state, "applied"); assert.equal(result.json().historyRecorded, false); assert.equal(dispatched, 3) } finally { await brokenHistory.close() }
|
|
reset(); mode = "provider-error"; await assert.rejects(prepare(), (error: Error) => !error.message.includes("NEVER_"))
|
|
assert.ok(!JSON.stringify((await pool.query("SELECT result FROM mcp_requests WHERE connection_id=$1", [connectionId])).rows).includes("NEVER_"))
|
|
console.log("✓ Shared IPsec user delete: full certificate optional cascade, namespaces, shared listener/mode-config/cert refs, CRL/protection, every-stage lost/ignored/collateral, checkpoints, history failure, late grant revoke, privacy and no replay")
|
|
} finally {
|
|
if (connectionId) await setConnectionStatus(connectionId, "paused", actor)
|
|
await pool.query("DELETE FROM mcp_protections WHERE object_ref->>'serverId'=$1", [String(serverId)]); await pool.query("DELETE FROM servers WHERE id=$1", [serverId])
|
|
await rest.close(); router.closeAllConnections(); await new Promise<void>((resolve, reject) => router.close(error => error ? reject(error) : resolve()))
|
|
}
|
|
}
|