Files
MikrotikManager/backend/src/modules/mcp/chr-wireguard-interface.ts
T

55 lines
6.2 KiB
TypeScript

import assert from "node:assert/strict"
import { pool } from "../../db/index.js"
import { generateWireGuardKeyPair } from "../../services/wg-keys.js"
import { readWireguardInterfaceDeleteState } from "../../services/wireguard-interface-delete.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import { invokeMcpTool } from "./runtime.js"
import type { McpPrincipal } from "./store.js"
/** Explicit cascade on an owned disabled interface, two disabled peers and disabled v4/v6 addresses. */
export async function acceptChrWireguardInterface(marker: string, principal: McpPrincipal, execute: (tool: string, args: Record<string, unknown>) => Promise<string>, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
assert.match(marker, /^mcp-test-[a-f0-9]{8}$/)
const serverId = 1, name = marker + "-wg-cascade", keys = [generateWireGuardKeyPair().publicKey, generateWireGuardKeyPair().publicKey]
const { server } = await routerClient(serverId), privateBaseline = await readWireguardInterfaceDeleteState(server, "*0")
const resources = ["wireguard", "wireguard_peer", "ipv4_address", "ipv6_address"]
const baselines = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
assert.ok(baselines.every(snapshot => !snapshot.rows.some(row => row.name === name || row.comment === marker)))
checks.push({ phase: "wg-interface-preflight", baselineFingerprints: baselines.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })), privateProofBaseline: privateBaseline.proof }); await checkpoint()
let cascadeStarted = false
try {
await execute("mm_wireguard_create_plan", { serverId, fields: { name, disabled: true, comment: marker } })
const own = (await readRosSnapshot(serverId, "wireguard")).rows.filter(row => row.name === name && row.comment === marker)
assert.equal(own.length, 1); assert.equal(own[0].disabled, "true")
const id = String(own[0][".id"])
for (const [index, publicKey] of keys.entries()) await execute("mm_wireguard_peer_create_plan", { serverId, fields: { interface: name, "public-key": publicKey, "allowed-address": index ? "2001:db8:243::2/128" : "198.19.243.2/32", disabled: true, comment: marker } })
await execute("mm_ipv4_address_create_plan", { serverId, fields: { address: "198.19.243.1/30", interface: name, disabled: true, comment: marker } })
await execute("mm_ipv6_address_create_plan", { serverId, fields: { address: "2001:db8:243::1/64", interface: name, disabled: true, advertise: false, comment: marker } })
await assert.rejects(invokeMcpTool(principal, "mm_wireguard_delete_plan", { serverId, id }), (error: { code?: string }) => error.code === "DEPENDENCY_CONFLICT")
checks.push({ phase: "wg-interface-owned-fixture", interfaceId: id, interfaceDisabled: true, peersDisabled: true, addressesDisabled: true, peers: 2, staticAddresses: 2, nativeAliasCascadeRefused: true }); await checkpoint()
cascadeStarted = true
const operationId = await execute("mm_wireguard_interface_remove_plan", { serverId, interfaceId: id })
const receipt = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=0", [operationId])).rows[0].result
assert.equal(receipt.verified, true); assert.equal(receipt.commandCount, 5); assert.equal(receipt.completedCommands, 5)
const reconciliation = await invokeMcpTool(principal, "mm_operation_reconcile", { operationId }) as { automaticRetry: boolean; results: { matchesDesiredState: boolean; matchesExpectedPrefixState: boolean; verifiedPrefixCommands: number }[] }
assert.equal(reconciliation.automaticRetry, false); assert.equal(reconciliation.results[0].matchesDesiredState, true); assert.equal(reconciliation.results[0].matchesExpectedPrefixState, true); assert.equal(reconciliation.results[0].verifiedPrefixCommands, 5)
const intents = (await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE connection_id=$1 AND action='configuration.intent' AND payload->>'method'='DELETE'", [principal.connectionId])).rows[0].n
assert.equal(intents, 5)
checks.push({ phase: "wg-interface-cascade-verified", operationId, commandCount: receipt.commandCount, completedCommands: receipt.completedCommands, nativeDeleteIntents: intents, perCommandReadBack: true, readOnlyReconciliation: true, revisionRecorded: receipt.revisionRecorded, forwardingVerified: false }); await checkpoint()
} finally {
const target = (await readRosSnapshot(serverId, "wireguard")).rows.filter(row => row.name === name && row.comment === marker)
const trace = checks.filter(row => row.phase === "mcp-operation" && row.tool === "mm_wireguard_interface_remove_plan").at(-1)
if (target.length && cascadeStarted && trace?.state !== "rejected" && !["prepare", "approval"].includes(String(trace?.stage))) {
checks.push({ phase: "wg-interface-cleanup-unconfirmed", marker, ownedDisabledFixtureRemaining: true, uncertainCascadeNotReplayed: true }); await checkpoint()
throw new Error("Owned WireGuard cascade requires read-only reconciliation; native commands will not be repeated")
}
if (target.length) {
for (const row of (await readRosSnapshot(serverId, "wireguard_peer")).rows.filter(row => row.interface === name && row.comment === marker && keys.includes(String(row["public-key"])))) await execute("mm_wireguard_peer_delete_plan", { serverId, id: row[".id"] })
for (const [resource, address] of [["ipv4_address", "198.19.243.1/30"], ["ipv6_address", "2001:db8:243::1/64"]]) for (const row of (await readRosSnapshot(serverId, resource)).rows.filter(row => row.interface === name && row.comment === marker && row.address === address)) await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
for (const row of target) await execute("mm_wireguard_delete_plan", { serverId, id: row[".id"] })
}
for (const baseline of baselines) assert.equal((await readRosSnapshot(serverId, baseline.resource)).fingerprint, baseline.fingerprint)
assert.equal((await readWireguardInterfaceDeleteState(server, "*0")).proof, privateBaseline.proof)
checks.push({ phase: "wg-interface-baseline-restored", fingerprints: baselines.length, privateProofRestored: true, staticConfigurationRestored: true, forwardingVerified: false }); await checkpoint()
}
}