feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

@@ -0,0 +1,77 @@
import { and, desc, eq, gte, lte } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { events } from "../../../db/schema.js"
import type { EventItem, EventLevel, EventSourceModule } from "@mmapp/contracts/events"
export type EventInsertInput = {
id: string
createdAt: string
level: EventLevel
eventType: string
sourceModule: EventSourceModule
title: string
message: string
entityType?: string
entityId?: string
payload?: Record<string, unknown>
}
export type ListEventsParams = {
limit: number
level?: EventLevel
sourceModule?: EventSourceModule
from?: string
to?: string
}
import { parseJsonObject } from "../../../db/json.js"
function mapEventRow(row: typeof events.$inferSelect): EventItem {
return {
id: row.id,
createdAt: row.createdAt,
level: row.level,
eventType: row.eventType,
sourceModule: row.sourceModule as EventSourceModule,
title: row.title,
message: row.message,
entityType: row.entityType ?? null,
entityId: row.entityId ?? null,
payload: parseJsonObject(row.payloadJson),
}
}
export async function insertEventsBatch(items: EventInsertInput[]) {
if (items.length === 0) return
await db.insert(events)
.values(
items.map((item) => ({
id: item.id,
createdAt: item.createdAt,
level: item.level,
eventType: item.eventType,
sourceModule: item.sourceModule,
title: item.title,
message: item.message,
entityType: item.entityType ?? null,
entityId: item.entityId ?? null,
payloadJson: item.payload ?? null,
})),
)
}
export async function listEvents(params: ListEventsParams): Promise<EventItem[]> {
const where = and(
params.level ? eq(events.level, params.level) : undefined,
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
params.from ? gte(events.createdAt, params.from) : undefined,
params.to ? lte(events.createdAt, params.to) : undefined,
)
const rows = await db
.select()
.from(events)
.where(where)
.orderBy(desc(events.createdAt))
.limit(params.limit)
return rows.map(mapEventRow)
}
@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto"
import {
appendEventSchema,
listEventsQuerySchema,
type AppendEventBody,
type EventItem,
type ListEventsQuery,
} from "@mmapp/contracts/events"
import { insertEventsBatch, listEvents, type EventInsertInput } from "../repository/events-repository.js"
import { executeGuardedApplicationWrite } from "../../../services/config-mutation-policy.js"
function normalizeEventInput(input: AppendEventBody): EventInsertInput {
return {
id: randomUUID(),
createdAt: input.createdAt ?? new Date().toISOString(),
level: input.level,
eventType: input.eventType.trim(),
sourceModule: input.sourceModule,
title: input.title.trim(),
message: input.message.trim(),
entityType: input.entityType?.trim() || undefined,
entityId: input.entityId?.trim() || undefined,
payload: input.payload ?? {},
}
}
export async function appendEvent(input: AppendEventBody) {
const parsed = appendEventSchema.parse(input)
await insertEventsBatch([normalizeEventInput(parsed)])
}
export async function appendEvents(inputs: AppendEventBody[]) {
const rows = prepareEventRows(inputs)
await insertEventsBatch(rows)
}
/** Stable server-generated identities let an approved batch be verified without replay. */
export function prepareEventRows(inputs: AppendEventBody[]): EventInsertInput[] {
return inputs.map(entry => normalizeEventInput(appendEventSchema.parse(entry)))
}
/** Client batch endpoint only. Internal collector/alert event publication retains
* its existing appendEvent/appendEvents contract and does not acquire this gate. */
export async function appendClientEventRows(rows: EventInsertInput[], beforeInsert?: () => Promise<void>) {
return executeGuardedApplicationWrite({ kind: "events-journal", id: "global" }, "all", async () => {
await beforeInsert?.()
await insertEventsBatch(rows)
}, async () => rows.map(row => ({ kind: "event", id: row.id })))
}
export async function readEvents(query: Partial<ListEventsQuery>): Promise<EventItem[]> {
const parsed = listEventsQuerySchema.parse(query)
return await listEvents(parsed)
}
@@ -0,0 +1,12 @@
import assert from "node:assert/strict"
import { acmeSettingsActionSchema, ACME_CLOUDFLARE_SECRET_PURPOSE } from "./acme-settings-actions.js"
assert.deepEqual(acmeSettingsActionSchema.parse({}), { settings: {} })
assert.deepEqual(acmeSettingsActionSchema.parse({ settings: { defaultZoneId: null }, secretRef: null }), { settings: { defaultZoneId: null }, secretRef: null })
for (const input of [
{ cloudflareApiToken: "PLAINTEXT" }, { settings: { cloudflareApiToken: "PLAINTEXT" } },
{ secretRef: "not-a-ref" }, { secretRef: "00000000-0000-4000-8000-000000000001", clearToken: true },
...["https://user:[email protected]/directory", "https://example.com/directory?secret=token", "file:///private/key", "https://example.com/directory#token"].map(directoryUrl => ({ settings: { directoryUrl } })),
]) assert.equal(acmeSettingsActionSchema.safeParse(input).success, false)
assert.equal(acmeSettingsActionSchema.safeParse({ settings: { directoryUrl: "https://directory.example/acme", defaultZoneId: "" }, secretRef: "00000000-0000-4000-8000-000000000001" }).success, true)
assert.equal(ACME_CLOUDFLARE_SECRET_PURPOSE, "acme-cloudflare:https://api.cloudflare.com/client/v4")
console.log("✓ ACME MCP schema: all public fields, reference replacement/explicit clear, no plaintext or credential-bearing URL")
@@ -0,0 +1,114 @@
import { z } from "zod"
import { randomUUID } from "node:crypto"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { performAcmeSettingsUpdate, readAcmeSettingsRecord } from "../../services/acme-settings-update.js"
import { planAcmeSettingsUpdate, publicAcmeSettings } from "../../services/acme-settings-update-model.js"
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
import { effectiveMcpConnection } from "./store.js"
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import type { add } from "./actions.js"
/** Cloudflare credentials are sent exclusively to this provider, never the ACME directory. */
export const ACME_CLOUDFLARE_SECRET_PURPOSE = "acme-cloudflare:https://api.cloudflare.com/client/v4"
const directory = z.string().url().max(2048).refine(value => {
try { const url = new URL(value); return ["http:","https:"].includes(url.protocol) && !url.username && !url.password && !url.hash && !url.search } catch { return false }
}, "ACME directory должен быть HTTP(S) URL без credentials/query/hash")
const fields = z.object({ directoryUrl: directory.optional(), defaultZoneId: z.string().max(4096).nullable().optional() }).strict()
export const acmeSettingsActionSchema = z.object({ settings: fields.default({}), secretRef: z.string().uuid().nullable().optional() }).strict()
const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409)
async function authorize(input: Record<string, unknown>, signal?: AbortSignal) {
signal?.throwIfAborted()
const principal = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict().parse(input.principal)
const connection = await effectiveMcpConnection(principal)
if (connection.owner_id !== input.ownerId || !connection.policy.scope.allowGlobal || !hasPermission(connection.policy.permissions, "mm:certificates:write") || !hasPermission(connection.owner_permissions, "mm:certificates:write")) throw fail("Право изменения настроек ACME отозвано", "PERMISSION_DENIED")
if (fingerprint(connection.policy) !== input.policyProof) throw fail("Допуск изменился", "RESET_REQUIRED")
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
if (ids.length > 1000 || ids.some(id => !Number.isSafeInteger(id) || id <= 0 || !connection.policy.scope.serverIds.includes(id))) throw fail("Настройки ACME затрагивают весь каталог серверов", "SERVER_DENIED")
signal?.throwIfAborted()
return ids
}
async function reference(input: Record<string, unknown>) {
if (!input.secretRef) return undefined
const secret = (await pool.query("SELECT CASE WHEN octet_length(purpose)<=512 THEN purpose ELSE NULL END AS purpose,server_id,CASE WHEN octet_length(ciphertext)<=16384 THEN ciphertext ELSE NULL END AS ciphertext,created_by,xmin::text AS revision FROM mcp_secrets WHERE id=$1 AND created_by=$2", [input.secretRef,input.ownerId])).rows[0]
if (!secret || typeof secret.ciphertext !== "string" || secret.server_id !== null || secret.created_by !== input.ownerId || secret.purpose !== ACME_CLOUDFLARE_SECRET_PURPOSE) throw fail("Токен не выдан владельцу для Cloudflare API", "SECRET_UNAVAILABLE")
let value: string
try { value = await decryptSecret(secret.ciphertext) } catch { throw fail("Токен Cloudflare недоступен", "SECRET_UNAVAILABLE") }
if (!value.trim() || Buffer.byteLength(value,"utf8") > 4096 || /[\r\n\u0000]/.test(value)) throw fail("Формат выданного токена Cloudflare не поддерживается", "SECRET_UNAVAILABLE")
return { value, proof: await privateSecretFingerprint(fingerprint(secret), "acme-settings-reference-v1") }
}
async function source(input: Record<string, unknown>, signal?: AbortSignal) {
// Full scope is checked before either the settings secrets or reference are read.
const ids = await authorize(input, signal), row = await readAcmeSettingsRecord()
if (row && (!directory.safeParse(row.directoryUrl).success || typeof row.defaultZoneId !== "string" || row.defaultZoneId.length > 4096)) throw fail("Текущая конфигурация ACME не поддерживается безопасным адаптером", "STATE_UNAVAILABLE")
const busy = (await pool.query("SELECT id FROM certificate_issue_jobs WHERE status IN ('queued','running') ORDER BY id LIMIT 1")).rows.length > 0
if (busy) throw fail("ACME занято выпуском сертификата")
const ref = await reference(input)
const protections = (await pool.query("SELECT id,xmin::text AS revision,CASE WHEN octet_length(object_ref::text)<=65536 THEN object_ref ELSE NULL END AS object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR object_ref->>'kind'='acme-settings' ORDER BY id LIMIT 1001", [ids.map(String)])).rows
if (protections.length > 1000 || protections.some(row=>!row.object_ref)) throw fail("Превышен предел защит ACME", "STATE_UNAVAILABLE")
if (fingerprint(await authorize(input, signal)) !== fingerprint(ids)) throw fail("Каталог серверов изменился при чтении ACME")
// Sealed plans round-trip through JSON; optional public DTO fields must not
// turn into a different source fingerprint merely because JSON drops undefined.
const state = { ...publicAcmeSettings(row), defaultZoneId: row?.defaultZoneId ?? "", updatedAt: row?.updatedAt ?? null, proof: await privateSecretFingerprint(fingerprint({ row: row ?? null, ids, protections, reference: ref?.proof ?? null }), "acme-settings-source-v2") }
return { ids, row, ref, state }
}
export function registerAcmeSettingsActions(register: typeof add) {
const current = async (input: Record<string, unknown>) => (await source(input)).state
registerMcpService("certificates.acme.update", { current,
execute: async input => {
if (input.proofRevision !== 2 || !z.string().uuid().safeParse(input.receiptId).success) throw fail("Нет совместимого proof настроек ACME", "UNSUPPORTED_CAPABILITY")
const before = await source(input)
if (fingerprint(before.state) !== input.beforeFingerprint) throw fail("Настройки ACME, каталог или ссылка на токен изменились после подтверждения")
const patch = { ...fields.parse(input.settings), ...(input.secretRef === null ? { cloudflareApiToken: null } : before.ref ? { cloudflareApiToken: before.ref.value } : {}) }
const fence = async () => {
await assertVerifiedConfigurationAccess()
if (fingerprint((await source(input)).state) !== input.beforeFingerprint) throw fail("Настройки ACME изменились перед SQL")
await assertVerifiedConfigurationAccess()
}
let dispatched = false
try {
await fence()
const result = await performAcmeSettingsUpdate(patch, { expected: before.row ?? null, beforeWrite: async () => { await fence(); dispatched = true }, afterWrite: async () => { await assertVerifiedConfigurationAccess(); await authorize(input) } })
const after = await source(input)
const desired = { ...planAcmeSettingsUpdate(before.row, patch, result.saved.updatedAt), revision: result.saved.revision }
if (fingerprint(after.row) !== fingerprint(result.saved) || fingerprint(desired) !== fingerprint(after.row)) return { configurationSaved: false, receiptId: input.receiptId, configurationMismatch: true, attempts: 1, automaticReplay: false }
return { configurationSaved: true, receiptId: input.receiptId, settings: publicAcmeSettings(after.row), stateProof: after.state.proof, providerTested: false, certificateIssued: false, attempts: 1, automaticReplay: false }
} catch (error) {
if (dispatched) throw new McpError("MUTATION_OUTCOME_UNKNOWN", "Итог записи ACME не подтверждён; автоматический повтор запрещён", 502)
throw error
}
},
verify: async (input, result) => {
const receipt = result as { configurationSaved?: boolean; stateProof?: string; receiptId?: unknown } | null
return receipt?.configurationSaved === true && receipt.receiptId === input.receiptId && (await current(input)).proof === receipt.stateProof
},
reconcile: async (input, allow) => {
if (input.proofRevision !== 2 || !z.string().uuid().safeParse(input.receiptId).success || typeof input.beforeFingerprint !== "string") throw fail("Нет совместимого proof и checkpoint настроек ACME", "UNSUPPORTED_CAPABILITY")
await allow(); const before = await current(input)
const principal = input.principal as { connectionId: string }
const checkpoints = (await pool.query("SELECT s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id WHERE o.connection_id=$1 AND s.state='verified' AND s.intended_state=$2::jsonb AND s.result->>'configurationSaved'='true' AND s.result->>'receiptId'=$3 LIMIT 2", [principal.connectionId,JSON.stringify(redact({kind:"service",service:"certificates.acme.update",input})),input.receiptId])).rows
const receipt = checkpoints.length === 1 ? checkpoints[0].result : undefined
const desired = typeof receipt?.stateProof === "string" && /^[a-f0-9]{64}$/.test(receipt.stateProof) && receipt.stateProof === before.proof
await allow(); const after = await current(input)
if (fingerprint(after) !== fingerprint(before)) throw fail("Настройки ACME изменились во время сверки")
// Only an exact verified checkpoint can attribute the final write; an
// unknown attempt cannot adopt a later token with coincidentally equal values.
return { matchesDesiredState: desired, matchesOriginalState: fingerprint(after) === input.beforeFingerprint }
},
})
register("certificates.acme.update", "mm_acme_settings_update_plan", "Сохранить настройки ACME/Cloudflare с отдельной ссылкой на токен", "certificates", "mm:certificates:write", "write", "global", acmeSettingsActionSchema, {
prepare: async (input, context) => {
const request = { ...input, principal: context.principal, ownerId: context.connection.owner_id, policyProof: fingerprint(context.connection.policy) }
const before = await source(request, context.signal)
const objects = [{ kind: "acme-settings", id: "1" }, ...before.ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))]
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR object_ref->>'kind'='acme-settings' LIMIT 1001", [before.ids.map(String)])
if (protections.rows.length > 1000) throw fail("Превышен предел защит ACME", "STATE_UNAVAILABLE")
for (const { object_ref: ref } of protections.rows) objects.push(ref)
if (fingerprint((await source(request, context.signal)).state) !== fingerprint(before.state)) throw fail("Настройки ACME изменились при подготовке")
return { target: { scope: "global" }, objects, snapshots: [], before: before.state, requiresHuman: true, mutationCost: 1, verificationScope: "configuration",
steps: [{ kind: "service", service: "certificates.acme.update", input: { ...request, proofRevision: 2, receiptId: randomUUID(), beforeFingerprint: fingerprint(before.state) } }],
warnings: ["Глобальные directoryUrl и defaultZoneId влияют на последующие выпуски сертификатов для всех серверов. Активные/ожидающие выпуски блокируют изменение.", "Пропущенный secretRef сохраняет токен; null очищает его; выданная ссылка заменяет токен. Ссылка принадлежит владельцу и имеет purpose acme-cloudflare:https://api.cloudflare.com/client/v4.", "Изменяется только SQL-конфигурация. Нет запросов к ACME/Cloudflare, проверки токена, выпуска сертификата или изменения DNS. Существующий account key сохраняется.", "Неопределённый результат не повторяется и не подтверждается совпадением значений; требуется самостоятельная сверка человеком."] }
},
})
}
@@ -0,0 +1,64 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { performAcmeSettingsTest } from "../../services/acme-settings-test.js"
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
import { effectiveMcpConnection } from "./store.js"
import { fingerprint, privateSecretFingerprint, McpError } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import type { add } from "./actions.js"
const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409)
/** Token material is never part of the public state or service input. */
export async function readAcmeTestSource(input: Record<string, unknown>, signal?: AbortSignal) {
const authorize = async () => {
if (signal?.aborted) throw fail("Проверка отменена", "READ_CANCELLED")
const principal = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict().parse(input.principal)
const current = await effectiveMcpConnection(principal)
if (current.owner_id !== input.ownerId || !current.policy.scope.allowGlobal || !hasPermission(current.policy.permissions, "mm:certificates:write") || !hasPermission(current.owner_permissions, "mm:certificates:write")) throw fail("Право проверки ACME отозвано", "PERMISSION_DENIED")
if (fingerprint(current.policy) !== input.policyProof) throw fail("Допуск изменился", "RESET_REQUIRED")
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => row.id)
if (ids.length > 1000 || ids.some(id => !current.policy.scope.serverIds.includes(id))) throw fail("Проверка ACME требует полный каталог серверов", "SERVER_DENIED")
if (signal?.aborted) throw fail("Проверка отменена", "READ_CANCELLED")
return ids
}
const ids = await authorize()
const row = (await pool.query<{ token: string | null; updated: unknown }>(`SELECT CASE WHEN octet_length(cloudflare_api_token)<=4096 THEN cloudflare_api_token ELSE NULL END AS token, updated_at AS updated FROM acme_settings WHERE id=1`)).rows[0]
if (!row || typeof row.token !== "string" || !row.token.trim() || /[\r\n\u0000]/.test(row.token)) throw fail("Сохранённый токен Cloudflare недоступен", "SECRET_UNAVAILABLE")
if (fingerprint(await authorize()) !== fingerprint(ids)) throw fail("Каталог серверов изменился")
return { ids, token: row.token.trim(), publicState: { tokenConfigured: true, destination: "https://api.cloudflare.com/client/v4/zones?per_page=1", proof: await privateSecretFingerprint(fingerprint({ row, ids }), "acme-token-test-v1") } }
}
export function registerAcmeTestActions(register: typeof add) {
const current = async (input: Record<string, unknown>) => (await readAcmeTestSource(input)).publicState
registerMcpService("certificates.acme.test", {
current,
execute: async input => {
const source = await readAcmeTestSource(input)
const fence = async () => {
await assertVerifiedConfigurationAccess()
if (fingerprint(await current(input)) !== input.beforeFingerprint) throw fail("Настройки ACME изменились после подтверждения")
}
await fence()
const result = await performAcmeSettingsTest({}, { readStored: async () => source.token, beforeDispatch: fence, afterDispatch: fence })
if (result.outcome === "unknown") throw new McpError("MUTATION_OUTCOME_UNKNOWN", "Ответ Cloudflare не подтверждён; автоматический повтор запрещён", 502)
return { providerAccepted: result.ok, outcome: result.outcome, attempts: 1, configurationChanged: false, certificateIssued: false, dnsChanged: false }
},
verify: async (_input, result) => (result as { providerAccepted?: boolean } | null)?.providerAccepted === true,
reconcile: async (_input, authorize) => { await authorize(); return { matchesDesiredState: false, matchesOriginalState: false } },
})
register("certificates.acme.test", "mm_acme_settings_test_plan", "Проверить сохранённый Cloudflare token одним запросом", "certificates", "mm:certificates:write", "write", "global", z.object({}).strict(), {
prepare: async (input, context) => {
const request = { ...input, principal: context.principal, ownerId: context.connection.owner_id, policyProof: fingerprint(context.connection.policy) }
const source = await readAcmeTestSource(request, context.signal)
const objects = [{ kind: "acme-settings", id: "1" }, { kind: "acme-settings-test", id: "1" }, ...source.ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))]
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR object_ref->>'kind' IN ('acme-settings','acme-settings-test') LIMIT 1001", [source.ids.map(String)])
if (protections.rows.length > 1000) throw fail("Превышен предел защит ACME", "STATE_UNAVAILABLE")
for (const { object_ref: ref } of protections.rows) objects.push(ref)
if (fingerprint((await readAcmeTestSource(request, context.signal)).publicState) !== fingerprint(source.publicState)) throw fail("Настройки ACME изменились во время подготовки")
return { target: { scope: "global" }, objects, snapshots: [], before: source.publicState, requiresHuman: true, mutationCost: 1, verificationScope: "acknowledgement",
steps: [{ kind: "service", service: "certificates.acme.test", input: { ...request, beforeFingerprint: fingerprint(source.publicState) } }],
warnings: ["Один GET Cloudflare /zones с сохранённым токеном. Настройки, DNS, ACME-аккаунт и сертификаты не изменяются.", "Ответ подтверждает только доступ к Cloudflare API. Unknown не повторяется; сверка не выполняет новый запрос.", "Для проверки другого токена человек использует REST UI; модель не принимает и не получает plaintext токена."] }
},
})
}
+42
View File
@@ -0,0 +1,42 @@
import type { z } from "zod"
import type { PoolClient } from "pg"
import type { McpOperationDescriptor, McpPolicyPreviewRequest } from "@mmapp/contracts/mcp"
import type { ConnectionRow, McpPrincipal } from "./store.js"
import type { RosSnapshot, RosStep } from "./resources.js"
export interface ActionContext {
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal
/** Process-internal persistent controller identity; never parsed from tool JSON. */
persistentRoutingProfile?: { id: string; version: number }
/** Available only during target resolution, never retained by the read/prepare handler. */
transactionClient?: Pick<PoolClient, "query">
}
export type ChangeStep = { kind: "ros"; step: RosStep } | { kind: "service"; service: string; input: Record<string, unknown> }
export interface ChangeEvidence {
/** Explicit opt-in on typed VRF plans; adds private precondition evidence,
* never permission for native companion side effects. */
vrfProductionAdmission?: true;
target: McpPolicyPreviewRequest["target"];
objects: McpPolicyPreviewRequest["objects"];
snapshots: RosSnapshot[];
steps: ChangeStep[];
/** Server-calculated upper bound for batched service mutations. */
mutationCost?: number;
before?: unknown;
requiresHuman?: boolean;
verificationScope?: "configuration" | "acknowledgement";
validUntil?: string;
observationChecks?: Array<{ id: string; srcServerId: number; dstServerId: number; srcInterface: string; family: string; table: string; fingerprint: string; validUntil: string }>;
ospfOwnerGraph?: { family: "ipv4" | "ipv6"; sourceSecretProofs: Array<{ serverId: number; proof: string }>; instances: Array<{ serverId: number; fingerprint: string }> };
managementChannels?: Array<{ channelId: string; serverId: number; expiresAt: string }>;
recovery?: { step: ChangeStep; expected: unknown; validUntil: string; authorityProfileId: string };
healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record<string, string> }>;
warnings: string[];
}
export interface McpAction {
operation: McpOperationDescriptor;
schema: z.ZodObject;
target: (input: Record<string, unknown>, context: ActionContext) => McpPolicyPreviewRequest["target"] | Promise<McpPolicyPreviewRequest["target"]>;
read?: (input: Record<string, unknown>, context: ActionContext) => Promise<unknown>;
prepare?: (input: Record<string, unknown>, context: ActionContext) => Promise<ChangeEvidence>;
}
+547
View File
@@ -0,0 +1,547 @@
import { registerIpsecServerDeleteActions } from "./ipsec-server-delete-actions.js"
import { registerIpsecServerInitActions } from "./ipsec-server-init-actions.js"
import { registerIpsecRevisionActions } from "./ipsec-revision-actions.js"
import { registerWireguardRevisionActions } from "./wireguard-revision-actions.js"
import { registerFlowHostKeysActions } from "./flow-host-keys-actions.js"
import { registerIpsecUserCreateActions } from "./ipsec-user-create-actions.js"
import { registerFlowSettingsActions } from "./flow-settings-actions.js"
import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-certificate-export-registration.js"
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
import { isIP } from "node:net"
import { z } from "zod"
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
import type { McpOperationDescriptor } from "@mmapp/contracts/mcp"
import { hasPermission } from "../../lib/permissions.js"
import { pool } from "../../db/index.js"
import { getServerReadById } from "../servers/service/servers-service.js"
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState } from "../users/service/users-service.js"
import { getLogSettings } from "../../services/router-logs-collector.js"
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
import { logWhere } from "../../services/router-logs-model.js"
import { readBoundedLogExportRows, serializeLogExport, withLogExportSlot, LogExportLimitError, LogExportBusyError } from "../../services/router-logs-export.js"
import { assertLogExportAccess, createLogExportArtifact, registerLogArtifactActions } from "./log-artifacts.js"
import { registerAlertsOutboxActions } from "./alerts-outbox-actions.js"
import { gatewayReferencesTable, isDynamicRouteInsideTable } from "../../services/routing-gateway-reference.js"
import { readLogStream } from "../../services/router-logs-query.js"
import { subscribeLogDevice } from "../../services/router-logs-collector.js"
import { rosResources, readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate } from "./resources.js"
import { registerMcpOperation, listMcpOperations } from "./registry.js"
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, redact } from "./security.js"
import { validatePrincipal } from "./store.js"
import type { McpAction, ActionContext, ChangeEvidence } from "./action-types.js"
import { registerRoutingActions } from "./routing-actions.js"
import { registerApplicationActions } from "./application-actions.js"
import { registerProbeActions } from "./probe-actions.js"
import { registerServerProbeActions } from "./server-probe-registration.js"
import { registerServerPollAction } from "./server-poll-registration.js"
import { registerSpeedTestActions } from "./speed-test-actions.js"
import { registerManualApiPingActions } from "./manual-api-ping-actions.js"
import { registerManualUptimeActions } from "./manual-uptime-actions.js"
import { registerManualPingGroupActions } from "./manual-ping-group-actions.js"
import { registerManualTrafficActions } from "./manual-traffic-actions.js"
import { registerTrafficLiveActions } from "./traffic-live-actions.js"
import { registerTrafficHistoryActions } from "./traffic-history-actions.js"
import { registerSidebarCountsActions } from "./sidebar-counts-actions.js"
import { registerTrafficUserHistoryActions } from "./traffic-user-history-actions.js"
import { registerBgpExportActions } from "./bgp-export-actions.js"
import { registerBgpPrefixActions } from "./bgp-prefix-actions.js"
import { registerFilterWriteActions } from "./filter-write-actions.js"
import { registerFlowCatalogActions } from "./flow-catalog-actions.js"
import { registerFlowAnalyticsActions } from "./flow-analytics-actions.js"
import { registerFlowMapActions } from "./flow-map-actions.js"
import { registerFlowLiveActions } from "./flow-live-actions.js"
import { registerAcmeTestActions } from "./acme-test-actions.js"
import { registerAcmeSettingsActions } from "./acme-settings-actions.js"
import { registerGeoipUpdateActions } from "./geoip-update-actions.js"
import { registerBackupDownloadActions } from "./backup-download-actions.js"
import { registerFlowOverlayActions } from "./flow-overlay-actions.js"
import { registerWireguardInterfaceCreateActions } from "./wireguard-interface-create-actions.js"
import { registerWireguardPeerCreateActions } from "./wireguard-peer-create-actions.js"
import { registerHostFilesActions } from "./host-files-actions.js"
import { registerTelegramSettingsActions } from "./telegram-settings-actions.js"
import { registerTelegramTestActions } from "./telegram-test-actions.js"
import { registerOspfStateActions } from "./ospf-state-actions.js"
import { registerOspfRouteProofActions } from "./ospf-route-proof-actions.js"
import { registerLegacyOspfActions } from "./ospf-legacy-actions.js"
import { prepareWireguardInterfaceDeletion, registerWireguardInterfaceDeleteActions } from "./wireguard-interface-delete-actions.js"
import { prepareWireguardPeerUpdate, registerWireguardPeerUpdateActions } from "./wireguard-peer-update-actions.js"
import { wireguardPeerNativeUpdate } from "../../services/wireguard-peer-update.js"
import { prepareWireguardPeerDeletion, registerWireguardPeerDeleteActions } from "./wireguard-peer-delete-actions.js"
import { registerFirewallActions } from "./firewall-actions.js"
import { registerGreMutationActions } from "./gre-mutation-actions.js"
import { registerGreRevisionActions } from "./gre-revision-actions.js"
import { prepareGreNativeChange, registerGreNativeServices } from "./gre-native-actions.js"
import { registerVpnCatalogActions } from "./vpn-catalog-actions.js"
import { registerEvoBgpActions } from "./evobgp-actions.js"
import { registerRoutingProfileActions } from "./routing-profiles.js"
import { registerRoutingControls } from "./routing-controls.js"
import { registerLifecycleActions } from "./lifecycle-actions.js"
import { registerRecoveryActions } from "./recovery-actions.js"
import { registerPathActions } from "./path-verification.js"
import { registerDatabaseRestore } from "./database-restore.js"
import { registerDatabaseBackupActions } from "./database-backup-actions.js"
import { registerTerminalDiagnosticActions } from "./terminal-diagnostic-actions.js"
import { registerEventsBatchActions } from "./events-batch-actions.js"
import { registerGlobalReconciliationActions } from "./global-reconciliation-actions.js"
import { registerWireguardInterfaceUpdateActions } from "./wireguard-interface-update-actions.js"
import { registerWireguardImportActions } from "./wireguard-import-actions.js"
import { registerWireguardExportActions } from "./wireguard-export-actions.js"
import { registerServerConnectionActions } from "./server-connection-actions.js"
import { prepareIpsecPeerDeletion, registerIpsecPeerDeleteActions } from "./ipsec-peer-delete-actions.js"
import { registerIpsecUserDeleteActions } from "./ipsec-user-delete-actions.js"
import { prepareNativeIpsecCertificateDeletion, registerIpsecCertificateDeleteActions } from "./ipsec-certificate-delete-actions.js"
import { prepareNativeIpsecPeerUpdate, registerIpsecPeerUpdateActions } from "./ipsec-peer-update-actions.js"
import { registerFlowPurgeActions } from "./flow-purge-actions.js"
import { registerFlowFactsRebuildActions } from "./flow-facts-rebuild-actions.js"
import { wireguardInterfaceNativeUpdate } from "../../services/wireguard-interface-update.js"
import { addRoutingDependencies } from "./routing-dependencies.js"
import { registerConfigHistory } from "./config-history.js"
import { registerDeviceCatalogActions } from "./device-catalog-actions.js"
import { registerRecursiveRecovery } from "./recursive-recovery.js"
import { registerNetworkHostActions } from "./network-host-actions.js"
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-actions.js"
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const rowId = z.string().trim().min(1).max(200)
const paging = { limit: z.number().int().min(1).max(500).default(100), offset: z.number().int().min(0).max(1000000).default(0) }
const scope = (input: Record<string, unknown>, context: ActionContext) => ({ scope: "servers" as const, serverIds: typeof input.serverId === "number" ? [input.serverId] : context.connection.policy.scope.serverIds })
const actions: McpAction[] = []
export function add(id: string, toolName: string, title: string, section: string, permission: string | readonly string[] | null, effect: McpOperationDescriptor["effect"], targetScope: McpOperationDescriptor["scope"], schema: z.ZodObject, handlers: Pick<McpAction, "read" | "prepare"> & Partial<Pick<McpAction, "target">> & { version?: number }) {
const { version = 1, ...implementationHandlers } = handlers
const operation: McpOperationDescriptor = {
id, toolName, title, section, effect, scope: targetScope, risk: effect === "read" ? "ordinary" : "dangerous",
version, implementation: "available", requiredPermissions: typeof permission === "string" ? [permission] : permission ? [...permission] : [],
annotations: { readOnlyHint: effect === "read", destructiveHint: effect === "write", idempotentHint: effect === "read", openWorldHint: true },
}
registerMcpOperation(operation)
actions.push({ operation, schema, target: targetScope === "servers" ? scope : () => ({ scope: targetScope }), ...implementationHandlers })
}
registerConfigHistory(add)
registerDeviceCatalogActions(add)
registerRecursiveRecovery(add)
registerNetworkHostActions(add)
registerBgpFilterBindingActions(add)
registerBgpCommunityPolicyActions(add)
registerTrafficHistoryActions(add)
registerSidebarCountsActions(add)
registerLogArtifactActions(add)
registerWireguardExportActions(add)
registerAlertsOutboxActions(add)
registerTrafficUserHistoryActions(add)
registerBgpExportActions(add)
registerBgpPrefixActions(add)
registerFilterWriteActions(add)
for (const resource of rosResources) {
add(`ros.${resource.id}.list`, `mm_${resource.id}_list`, resource.title, resource.permission.split(":")[1], resource.permission + ":read", "read", "servers", z.object({ serverId, ...paging }).strict(), {
read: async input => {
const { client } = await routerClient(Number(input.serverId)), raw = await client.get<unknown>(resource.path)
const rows = Array.isArray(raw) ? raw : [raw], offset = Number(input.offset), limit = Number(input.limit)
return { items: redact(rows.slice(offset, offset + limit)), total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null, observedAt: new Date().toISOString() }
},
})
if (resource.readOnly) continue
for (const action of ["create", "update", "delete"] as const) {
if (action === "create" && !resource.create || action === "delete" && !resource.remove) continue
const schema = action === "create" || ["ip_settings", "ipv6_settings"].includes(resource.id)
? z.object({ serverId, fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict()
: z.object({ serverId, id: rowId.optional(), fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict()
const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete"
const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" ? 2 : 1
add(`ros.${resource.id}.${action}`, `mm_${resource.id}_${action}_plan`, `${resource.title}: ${action}`, resource.permission.split(":")[1], resource.id === "certificate" && action === "update" ? ["mm:certificates:write", "mm:network:write"] : resource.permission + ":write", "write", "servers", schema, {
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? 1 : 0),
prepare: async (input, context) => {
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
const sid = Number(input.serverId), id = input.id as string | undefined
const fields = validateRosStep(resource, action, input.fields, id)
if (resource.id === "ipsec_peer" && action === "create") return prepareIpsecNativeWrite("ros.ipsec_peer.create", sid, undefined, input.fields, context)
if (resource.id === "certificate" && action === "update") return prepareIpsecNativeWrite("ros.certificate.update", sid, id, input.fields, context)
if (resource.id === "ipsec_peer" && action === "delete") return prepareIpsecPeerDeletion(sid, String(id), context, false)
if (resource.id === "ipsec_peer" && action === "update") return prepareNativeIpsecPeerUpdate(sid, String(id), input.fields, context)
if (resource.id === "certificate" && action === "delete") return prepareNativeIpsecCertificateDeletion(sid, String(id), context)
if (resource.id === "wireguard" && action === "delete") return prepareWireguardInterfaceDeletion(sid, String(id), context, false)
if (resource.id === "wireguard_peer" && action === "delete") return prepareWireguardPeerDeletion(sid, String(id), context)
if (resource.id === "wireguard_peer" && action === "update") return prepareWireguardPeerUpdate(sid, String(id), wireguardPeerNativeUpdate(input.fields as Record<string, unknown>), context)
if (resource.id === "wireguard" && action === "update") return findAction("mm_wireguard_interface_edit_plan").prepare!({ serverId: sid, interfaceId: String(id), patch: wireguardInterfaceNativeUpdate(input.fields as Record<string, unknown>) }, context)
if (resource.id === "wireguard_peer" && action === "create") {
const { interface: parentReference, ...peerFields } = fields
const parents = (await readRosSnapshot(sid, "wireguard")).rows.filter(row => row[".id"] === parentReference || row.name === parentReference)
if (parents.length !== 1 || typeof parents[0].name !== "string") throw new McpError("STATE_CONFLICT", "Родитель WireGuard не найден однозначно", 409)
return findAction("mm_wireguard_peer_add_plan").prepare!({ serverId: sid, interfaceName: parents[0].name, ...wireguardPeerNativeUpdate(peerFields) }, context)
}
if (resource.id === "container" && action === "delete") return findAction("mm_container_remove_plan").prepare!({ serverId: sid, id }, context)
if (resource.id === "gre" && action !== "create") return prepareGreNativeChange(sid,action,id,input.fields)
for (const key of ["address", "dst-address"] as const) {
if (fields[key]) {
const [ip, prefix] = fields[key].split("/")
const expected = resource.id.startsWith("ipv6") ? 6 : 4
if (isIP(ip) !== expected || prefix !== undefined && (!/^\d+$/.test(prefix) || Number(prefix) > (expected === 6 ? 128 : 32))) throw new McpError("INVALID_INPUT", "Некорректный IP-префикс", 400)
}
}
const before = await readRosSnapshot(sid, resource.id)
const current = action === "create" ? undefined : id ? before.rows.find(row => row[".id"] === id) : before.rows[0]
if (action !== "create" && !current) throw new McpError("NOT_FOUND", "Объект RouterOS не найден", 404)
if (action !== "create" && (current?.dynamic === "true" || current?.builtin === "true" || current?.default === "true")) throw new McpError("PROTECTED_OBJECT", "Системный или динамический объект нельзя изменять")
if (resource.id.endsWith("settings") && Object.keys(fields).some(key => !(key in (current ?? {})))) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило поддержку свойства", 409)
const references: ChangeEvidence["objects"] = [{ kind: resource.id, id: id ?? String(fields.name ?? "new"), serverId: sid }]
if (resource.id.endsWith("settings")) references.push({ kind: resource.id, id: "singleton", serverId: sid })
const snapshots = [before]
if (["ospf_instance", "ospf_area", "ospf_template", "bfd_configuration", "bgp_instance", "bgp_connection", "vrf"].includes(resource.id)) await addRoutingDependencies(sid, resource.id, action, current, fields, snapshots, references)
if (["ipv4_route", "ipv6_route", "routing_rule", "bgp_connection"].includes(resource.id)) {
const tables = await readRosSnapshot(sid, "routing_table")
snapshots.push(tables)
const tableNames = new Set([current?.["routing-table"], fields["routing-table"], resource.id === "routing_rule" ? current?.table : undefined, resource.id === "routing_rule" ? fields.table : undefined].filter(value => typeof value === "string" && value.length))
if (["ipv4_route", "ipv6_route"].includes(resource.id) && !tableNames.size) tableNames.add("main")
for (const name of tableNames) {
const table = tables.rows.find(row => row.name === name)
if (!table || table.disabled === "true") throw new McpError("STATE_CONFLICT", "Таблица маршрутизации отсутствует или отключена", 409)
references.push({ kind: "routing_table", id: String(table[".id"]), serverId: sid })
}
}
if (resource.id === "routing_table" && action !== "create" && (action === "delete" || fields.name && fields.name !== current?.name || fields.disabled === "true")) {
// Keep exact dependency snapshots in the sealed plan: a route/rule added
// after approval must also prevent deleting or renaming its table.
const tableName = String(current?.name ?? "")
if (!tableName) throw new McpError("STATE_UNAVAILABLE", "Не подтверждено имя таблицы маршрутизации", 409)
for (const dependency of ["ipv4_route", "ipv6_route", "routing_rule", "vrf", "ospf_instance", "bgp_instance", "bgp_connection"]) {
const snapshot = await readRosSnapshot(sid, dependency)
snapshots.push(snapshot)
let inUse: boolean
try { inUse = snapshot.rows.some(row => row["routing-table"] === tableName || dependency === "routing_rule" && row.table === tableName || dependency === "vrf" && row.name === tableName || ["ipv4_route", "ipv6_route"].includes(dependency) && gatewayReferencesTable(row.gateway, [tableName, String(current?.[".id"])])) }
catch { throw new McpError("STATE_UNAVAILABLE", "Не подтверждены gateway dependencies таблицы", 409) }
if (inUse) throw new McpError("STATE_CONFLICT", "Таблица используется маршрутом, gateway, правилом, VRF или протоколом маршрутизации", 409)
}
}
// Inventory proves references and captures dependency changes between preview and apply.
const interfaceResources = ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"]
if ([...interfaceResources, "bridge_port", "bridge_vlan", "vrf", "ipv4_address", "ipv6_address", "ospf_template", "bfd_configuration"].includes(resource.id)) {
for (const dependency of [...interfaceResources, "bridge_port", "bridge_vlan", "ipv4_address", "ipv6_address", "vrf"]) {
if (dependency === resource.id || snapshots.some(snapshot => snapshot.resource === dependency)) continue
try { snapshots.push(await readRosSnapshot(sid, dependency)) }
catch { throw new McpError("STATE_CONFLICT", "Не удалось подтвердить зависимости интерфейса", 409) }
}
const inventory = snapshots.filter(snapshot => interfaceResources.includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({ resource: snapshot.resource, row })))
const interfaces = new Set(inventory.map(item => String(item.row.name)))
for (const field of ["interface", "bridge"] as const) if (fields[field] && !interfaces.has(fields[field])) throw new McpError("INVALID_INPUT", "Неизвестный родительский интерфейс", 400)
const vrfMembers = ["vrf", "ospf_template", "bfd_configuration"].includes(resource.id) ? [current?.interfaces, fields.interfaces].filter((value): value is string => typeof value === "string" && !["all", "none"].includes(value)).flatMap(value => value.split(",")) : []
if (resource.id === "vrf" && fields.interfaces !== undefined && (!fields.interfaces.trim() || fields.interfaces.split(",").includes("all"))) throw new McpError("INVALID_INPUT", "Выберите конкретные интерфейсы VRF или none", 400)
const referenced = [current?.interface, current?.bridge, fields.interface, fields.bridge, ...vrfMembers, ...(resource.id === "bridge_vlan" ? [fields.tagged, fields.untagged].filter(Boolean).flatMap(value => value.split(",")) : [])].filter((value): value is string => typeof value === "string" && value.length > 0).map(value => value.trim())
for (const name of new Set(referenced)) {
const parent = inventory.find(item => item.row.name === name)
if (!parent) throw new McpError("INVALID_INPUT", "Требуется явный существующий интерфейс", 400)
references.push({ kind: parent.resource, id: String(parent.row[".id"]), serverId: sid })
}
if (resource.id === "bridge_vlan") {
for (const iface of [fields.tagged, fields.untagged].filter(Boolean).flatMap(value => value.split(","))) if (!interfaces.has(iface.trim())) throw new McpError("INVALID_INPUT", "Неизвестный участник VLAN", 400)
if (fields.tagged?.split(",").some(iface => fields.untagged?.split(",").includes(iface))) throw new McpError("INVALID_INPUT", "Порт не может быть одновременно tagged и untagged", 400)
}
const existingName = String(current?.name ?? "")
if ((action === "delete" || fields.name && fields.name !== existingName) && existingName) {
if (interfaceResources.includes(resource.id)) for (const dependency of ["ospf_template", "bfd_configuration"]) snapshots.push(await readRosSnapshot(sid, dependency))
const dependent = snapshots.filter(snapshot => snapshot.resource !== resource.id).some(snapshot => snapshot.rows.some(row => {
if (resource.id === "vrf" && ["ipv4_route", "ipv6_route"].includes(snapshot.resource) && isDynamicRouteInsideTable(row, [existingName, String(current?.[".id"])])) return false
return Object.values(row).some(value => typeof value === "string" && value.split(",").includes(existingName))
}))
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
}
}
const placeBeforeId = resource.id === "vrf" && action === "create" ? before.rows[0]?.[".id"] : undefined
if (resource.id === "vrf" && action === "create" && typeof placeBeforeId !== "string") throw new McpError("STATE_CONFLICT", "Не подтверждён порядок существующих VRF", 409)
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
requiresHuman: true,
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
}
},
})
}
}
for (const definition of [
{ id: "interfaces.update", tool: "mm_interfaces_update_plan", title: "План изменения интерфейса", resources: ["ethernet", "bridge", "bridge_port", "bridge_vlan", "vlan", "gre", "wireguard", "vxlan"] },
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
] as const) {
add(definition.id, definition.tool, definition.title, "network", "mm:network:write", "write", "servers", z.object({ serverId, resource: z.enum(definition.resources), id: rowId, fields: z.record(z.string().max(100), z.union([z.string().max(1000), z.number().finite(), z.boolean()])) }).strict(), {
version: definition.id === "interfaces.update" ? 4 : 2,
prepare: async (input, context) => {
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
const handler = actions.find(action => action.operation.id === `ros.${resource.id}.update`)
if (!handler?.prepare) throw new McpError("UNSUPPORTED_CAPABILITY", "Этот объект не поддерживает изменение", 409)
return handler.prepare({ serverId: input.serverId, id: input.id, fields: fields.data }, context)
},
})
}
add("capabilities.read", "mm_capabilities_get", "Доступные возможности", "mcp", null, "read", "connection", z.object({}).strict(), {
read: async (_input, context) => ({ operations: listMcpOperations().filter(operation => operation.implementation === "available" && context.connection.policy.scope.operationIds.includes(operation.id)), schemaVersion: 1 }),
})
add("servers.list", "mm_servers_list", "Серверы", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: serverId.optional(), ...paging }).strict(), {
read: async (input, context) => {
const ids = typeof input.serverId === "number" ? [input.serverId] : context.connection.policy.scope.serverIds
const { rows } = await pool.query<{ id: number }>("SELECT id FROM servers WHERE id=ANY($1::bigint[]) ORDER BY id LIMIT $2 OFFSET $3", [ids, input.limit, input.offset])
return { items: await Promise.all(rows.map(row => getServerReadById(Number(row.id)))), observedAt: new Date().toISOString() }
},
})
function scopedUser(user: Awaited<ReturnType<typeof getUserById>> | null, context: ActionContext) {
if (!user) throw new McpError("NOT_FOUND", "Пользователь не найден", 404)
const allowed = context.connection.policy.scope.serverIds
const hidden = user.bindings.some(binding => !allowed.includes(binding.serverId)) || user.servers.some(server => !allowed.includes(Number(server.serverId)))
return { ...user, bindings: user.bindings.filter(binding => allowed.includes(binding.serverId)), servers: user.servers.filter(server => allowed.includes(Number(server.serverId))), incomplete: hidden }
}
add("users.list", "mm_users_list", "Пользователи", "users", "mm:users:read", "read", "global", z.object(paging).strict(), {
read: async (input, context) => ({ items: (await listUsers()).slice(Number(input.offset), Number(input.offset) + Number(input.limit)).map(user => scopedUser(user, context)) }),
})
add("users.get", "mm_users_get", "Пользователь по ID", "users", "mm:users:read", "read", "global", z.object({ id: rowId }).strict(), {
read: async (input, context) => ({ user: scopedUser(await getUserById(String(input.id)), context) }),
})
add("users.interfaces.read", "mm_user_interfaces_list", "Каталог интерфейсов для привязок пользователей", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), {
read: async input => ({ interfaces: await listInterfaceCatalog(Number(input.serverId)) }),
})
add("users.interfaces.state", "mm_user_interfaces_state_get", "Состояние каталога интерфейсов для привязок", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), {
read: async input => getInterfaceCatalogState(Number(input.serverId)),
})
for (const action of ["create", "update", "delete", "binding_add", "binding_remove"] as const) {
const schema = action === "create" ? z.object({ user: appUserCreateSchema.omit({ role: true, sections: true, servers: true }).strict() }).strict()
: action === "update" ? z.object({ id: rowId, user: appUserUpdateSchema.omit({ role: true, sections: true, servers: true }).strict() }).strict()
: action === "binding_add" ? z.object({ id: rowId, binding: userBindingCreateSchema }).strict()
: action === "binding_remove" ? z.object({ id: rowId, bindingId: rowId }).strict()
: z.object({ id: rowId }).strict()
add(`users.${action}`, `mm_users_${action}_plan`, `Пользователи: ${action}`, "users", "mm:users:write", "write", "global", schema, {
prepare: async (input, context) => {
const before = input.id ? await getUserById(String(input.id)) : null
if (input.id && scopedUser(before, context).incomplete) throw new McpError("SERVER_DENIED", "Есть зависимости за пределами доступа")
const user = input.user as { servers?: { serverId: string }[] } | undefined
const binding = input.binding as { serverId?: number } | undefined
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
...(before?.bindings ?? []).map(binding => binding.serverId), ...(before?.servers ?? []).map(server => Number(server.serverId))])]
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id ?? "new") }, ...ids.map(id => ({ kind: "server", id: String(id), serverId: id }))], snapshots: [], before,
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
},
})
}
add("users.permissions.update", "mm_users_permissions_update_plan", "Роль и права каталога /users", "users", "mm:settings:admin", "write", "global", z.object({ id: rowId, permissions: z.object({ role: appUserRoleSchema.optional(), sections: z.array(sectionPermSchema.strict()).max(100).optional(), servers: z.array(serverPermSchema.strict()).max(1000).optional() }).strict().refine(value => Object.keys(value).length > 0, "Нет изменений прав") }).strict(), {
prepare: async (input, context) => {
const before = await getUserById(String(input.id)), visible = scopedUser(before, context)
if (visible.incomplete) throw new McpError("SERVER_DENIED", "Права пользователя затрагивают скрытые серверы")
const permissions = input.permissions as { servers?: { serverId: string }[] }
const serverIds = [...new Set([...(before?.servers ?? []).map(server => Number(server.serverId)), ...(before?.bindings ?? []).map(binding => binding.serverId), ...(permissions.servers ?? []).map(server => Number(server.serverId))])]
if (serverIds.some(serverId => !Number.isSafeInteger(serverId) || !context.connection.policy.scope.serverIds.includes(serverId))) throw new McpError("SERVER_DENIED", "Назначаемые права выходят за область подключения")
if (serverIds.length && (await pool.query("SELECT id FROM servers WHERE id=ANY($1::bigint[])", [serverIds])).rows.length !== serverIds.length) throw new McpError("NOT_FOUND", "Назначаемый сервер отсутствует", 404)
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id) }, ...serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId }))], snapshots: [], before, requiresHuman: true, steps: [{ kind: "service", service: "users.update", input: { id: input.id, user: input.permissions } }], warnings: ["Роль и права относятся к каталогу /users и не изменяют JWT или аккаунты портала."] }
},
})
const logInput = z.object({ serverId: serverId.optional(), from: z.string().datetime({ offset: true }).optional(), to: z.string().datetime({ offset: true }).optional(),
levels: z.array(z.enum(["critical", "error", "warning", "info", "debug", "unknown"])).max(6).default([]), topics: z.array(z.string().max(100)).max(30).default([]),
q: z.string().max(500).default(""), preset: z.enum(["", "login-failure", "interfaces", "routing", "firewall"]).default(""),
limit: z.number().int().min(1).max(200).default(100), cursor: z.string().max(3000).optional(),
}).strict()
function logFilters(input: Record<string, unknown>, context: ActionContext) {
return { ...logFiltersSchema.parse({ ...input, levels: (input.levels as string[]).join(","), topics: (input.topics as string[]).join(",") }), allowedServerIds: context.connection.policy.scope.serverIds }
}
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
read: async (input, context) => {
const filters = logFilters(input, context), generation = await accessGeneration()
let cursor: string | undefined, watermark: string | undefined
if (input.cursor) {
try {
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
if (input.from === undefined) filters.from = state.from
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
cursor = state.cursor; watermark = state.watermark
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
}
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
return { ...result, nextCursor: result.nextCursor ? await encryptSecret(JSON.stringify({ generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor: result.nextCursor, watermark: result.watermark, from: filters.from })) : null }
},
})
add("logs.summary", "mm_logs_summary_get", "Статистика логов", "logs", "mm:servers:read", "read", "servers", logInput, {
read: async (input, context) => { const { settings: _settings, ...summary } = await readLogSummary(logFilters(input, context)); return summary },
})
for (const context of [false, true]) add(context ? "logs.context" : "logs.get", context ? "mm_logs_context_get" : "mm_logs_get", "Запись журнала", "logs", "mm:servers:read", "read", "servers", z.object({ serverId, id: z.string().regex(/^\d{1,20}$/) }).strict(), {
read: async input => {
const entry = await readLogEntry(String(input.id))
if (!entry || entry.serverId !== input.serverId) throw new McpError("NOT_FOUND", "Событие не найдено", 404)
return context ? readLogContext(String(input.id)) : entry
},
})
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
read: async (input, context) => {
const { server } = await routerClient(Number(input.serverId)); void server
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
try {
const filters = logFilters(input, context), generation = await accessGeneration()
let cursor = "0"
if (input.cursor) {
try {
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
filters.from = saved.from
if (saved.kind !== "follow" || saved.generation !== generation || saved.scope !== fingerprint({ filters, connection: context.connection.id }) || !/^\d{1,20}$/.test(saved.cursor)) throw new Error("cursor")
cursor = saved.cursor
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
} else {
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
}
while (true) {
const current = await validatePrincipal(context.principal)
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
const batch = await readLogStream(filters, cursor)
cursor = batch.cursor
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
await new Promise(resolve => setTimeout(resolve, 1000))
}
} finally { release() }
},
})
add("logs.export", "mm_logs_export", "Экспорт логов", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ format: z.enum(["csv", "json"]).default("json") }), {
read: async (input, context) => {
const where = logWhere(logFilters(input, context))
const serverIds = input.serverId ? [Number(input.serverId)] : context.connection.policy.scope.serverIds
await assertLogExportAccess(context, serverIds)
try {
return await withLogExportSlot(async () => {
const rows = await readBoundedLogExportRows(where)
await assertLogExportAccess(context, serverIds)
const masked = rows.map(row => redact(row)) as Record<string, unknown>[]
const format = input.format as "json" | "csv", content = serializeLogExport(masked, format, false)
return { ...await createLogExportArtifact(content, format, serverIds, context), rows: rows.length }
})
} catch (error) { await assertLogExportAccess(context, serverIds); if (error instanceof LogExportLimitError) throw new McpError("EXPORT_LIMIT", error.message, 422); if (error instanceof LogExportBusyError) throw new McpError("EXPORT_BUSY", error.message, 429); throw error }
},
})
add("logs.settings.read", "mm_logs_settings_get", "Настройки логов", "logs", "mm:settings:admin", "read", "global", z.object({}).strict(), { read: async () => getLogSettings() })
add("logs.settings.update", "mm_logs_settings_update_plan", "Изменение сборщика логов", "logs", "mm:settings:admin", "write", "global", z.object({ settings: logSettingsPatchSchema }).strict(), {
prepare: async (input, context) => {
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id))
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка сборщика затрагивает скрытые серверы")
return { target: { scope: "global" }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: await getLogSettings(), steps: [{ kind: "service", service: "logs.settings.update", input }], warnings: ["Отключение сбора и уменьшение retention влияют на последующую доступность истории."] }
},
})
export function listActions(): readonly McpAction[] { return actions }
export function findAction(toolName: string): McpAction {
const action = actions.find(candidate => candidate.operation.toolName === toolName)
if (!action) throw new McpError("UNKNOWN_OPERATION", "Инструмент не реализован", 404)
return action
}
export function findActionByOperation(id: string): McpAction {
const action = actions.find(candidate => candidate.operation.id === id)
if (!action) throw new McpError("UNKNOWN_OPERATION", "Операция не реализована", 404)
return action
}
async function assertPlanReadScope(plan: Awaited<ReturnType<typeof import("./runtime.js")["loadPlan"]>>, context: ActionContext) {
if (plan.connection_id !== context.connection.id) throw new McpError("NOT_FOUND", "План не найден", 404)
if (!context.connection.policy.scope.operationIds.includes(plan.operation_id)) throw new McpError("OPERATION_DENIED", "Исходное действие не входит в текущий допуск")
const source = findActionByOperation(plan.operation_id)
if (source.operation.requiredPermissions.some(permission => !hasPermission(context.connection.policy.permissions, permission) || !hasPermission(context.connection.owner_permissions, permission))) throw new McpError("PERMISSION_DENIED", "Право исходного действия отозвано")
const evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence
const ids = [...(evidence.target.scope === "servers" ? evidence.target.serverIds : []), ...evidence.objects.flatMap(object => object.serverId === undefined ? [] : [object.serverId])]
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id)) || evidence.target.scope === "global" && !context.connection.policy.scope.allowGlobal) throw new McpError("SERVER_DENIED", "План находится вне текущего допуска")
}
add("changes.read", "mm_change_get", "Просмотр своего плана", "mcp", null, "read", "connection", z.object({ planId: z.string().uuid() }).strict(), {
read: async (input, context) => {
const { loadPlan, publicPlan } = await import("./runtime.js")
const plan = await loadPlan(String(input.planId))
await assertPlanReadScope(plan, context)
return publicPlan(plan)
},
})
add("changes.apply", "mm_change_apply", "Применить свой план", "mcp", null, "write", "connection", z.object({ planId: z.string().uuid(), idempotencyKey: z.string().min(1).max(200) }).strict(), {
read: async (input, context) => (await import("./runtime.js")).applyPlan(context.principal, String(input.planId), String(input.idempotencyKey)),
})
add("changes.cancel", "mm_change_cancel", "Отменить свой незапущенный план", "mcp", null, "read", "connection", z.object({ planId: z.string().uuid() }).strict(), {
read: async (input, context) => {
const { transaction, audit } = await import("./store.js")
await assertPlanReadScope(await (await import("./runtime.js")).loadPlan(String(input.planId)), context)
return transaction(async client => {
const { rows } = await client.query("UPDATE mcp_plans SET state='cancelled' WHERE id=$1 AND connection_id=$2 AND state IN ('draft','awaiting-approval','approved') RETURNING id", [input.planId, context.connection.id])
if (!rows.length) throw new McpError("STATE_CONFLICT", "План не найден или уже запущен", 409)
await audit(client, context.connection.owner_id, "plan.cancel", context.connection.id, { planId: input.planId })
return { cancelled: true }
})
},
})
add("operations.read", "mm_operation_get", "Результат своей операции", "mcp", null, "read", "connection", z.object({ operationId: z.string().uuid() }).strict(), {
read: async (input, context) => {
const { rows: [operation] } = await pool.query("SELECT * FROM mcp_operations WHERE id=$1 AND connection_id=$2", [input.operationId, context.connection.id])
if (!operation) throw new McpError("NOT_FOUND", "Операция не найдена", 404)
await assertPlanReadScope(await (await import("./runtime.js")).loadPlan(operation.plan_id), context)
const { rows: steps } = await pool.query("SELECT * FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index", [input.operationId])
return { ...operation, steps }
},
})
registerRoutingActions(add)
registerRoutingProfileActions(add)
registerRoutingControls(add)
registerLifecycleActions(add)
registerRecoveryActions(add)
registerPathActions(add)
registerApplicationActions(add)
registerProbeActions(add)
registerServerProbeActions(add)
registerServerPollAction(add)
registerSpeedTestActions(add)
registerManualApiPingActions(add)
registerManualUptimeActions(add)
registerManualPingGroupActions(add)
registerManualTrafficActions(add)
registerTrafficLiveActions(add)
registerFlowCatalogActions(add)
registerFlowAnalyticsActions(add)
registerFlowMapActions(add)
registerFlowLiveActions(add)
registerAcmeTestActions(add)
registerAcmeSettingsActions(add)
registerGeoipUpdateActions(add)
registerBackupDownloadActions(add)
registerFlowOverlayActions(add)
registerWireguardInterfaceCreateActions(add)
registerWireguardPeerCreateActions(add)
registerHostFilesActions(add)
registerTelegramSettingsActions(add)
registerTelegramTestActions(add)
registerOspfStateActions(add)
registerOspfRouteProofActions(add)
registerLegacyOspfActions(add)
registerWireguardPeerDeleteActions(add)
registerWireguardPeerUpdateActions(add)
registerWireguardInterfaceDeleteActions(add)
registerFirewallActions(add)
registerGreMutationActions(add)
registerGreRevisionActions(add)
registerGreNativeServices()
registerVpnCatalogActions(add)
registerEvoBgpActions(add)
registerDatabaseRestore(add)
registerDatabaseBackupActions(add)
registerTerminalDiagnosticActions(add)
registerEventsBatchActions(add)
registerGlobalReconciliationActions(add)
registerWireguardInterfaceUpdateActions(add)
registerWireguardImportActions(add)
registerServerConnectionActions(add)
registerIpsecPeerDeleteActions(add)
registerIpsecUserDeleteActions(add)
registerIpsecUserUpdateActions(add)
registerIpsecServerDeleteActions(add)
registerIpsecServerInitActions(add)
registerIpsecRevisionActions(add)
registerWireguardRevisionActions(add)
registerIpsecCertificateDeleteActions(add)
registerIpsecPeerUpdateActions(add)
registerFlowPurgeActions(add)
registerFlowFactsRebuildActions(add)
registerFlowHostKeysActions(add)
registerIpsecUserCreateActions(add)
registerFlowSettingsActions(add)
registerIpsecExistingCertificateExportActions(add)
@@ -0,0 +1,60 @@
import { z } from "zod"
import { alertsOutboxRetrySchema, alertsOutboxRetryReceiptSchema } from "@mmapp/contracts/alerts"
import { pool } from "../../db/index.js"
import { readOutboxRetrySource, retryUnknownOutbox, OutboxRetrySourceUnavailable, type OutboxRetrySource } from "../../services/alert-engine/outbox.js"
import { OutboxRetryConflict } from "../../services/alerts-outbox-retry.js"
import { privateSecretFingerprint, McpError } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import type { add } from "./actions.js"
const operation = "alerts.outbox.retry"
const schema = alertsOutboxRetrySchema.safeExtend({ id: z.string().min(1).max(200).refine(value => value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value)) })
const sourceProof = (source: OutboxRetrySource) => privateSecretFingerprint(JSON.stringify(source), "alerts-outbox-retry")
async function dependencyIds() {
const serverIds = (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
if (serverIds.length > 999) throw new McpError("STATE_UNAVAILABLE", "Каталог зависимостей очереди превышает 999 серверов", 409)
return serverIds
}
async function current(input: Record<string, unknown>) {
const serverIds = await dependencyIds()
const permitted = input.serverIds
if (Array.isArray(permitted) && serverIds.some(id => !permitted.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная очередь содержит зависимости вне области плана", 403)
let source: OutboxRetrySource | null
try { source = await readOutboxRetrySource(String(input.id)) }
catch (error) { if (error instanceof OutboxRetrySourceUnavailable) throw new McpError("STATE_UNAVAILABLE", error.message, 409); throw error }
return { serverIds, value: source ? { id: source.id, channel: source.channel, status: source.status, resolutionVersion: source.resolution_version, proof: await sourceProof(source) } : null }
}
export function registerAlertsOutboxActions(register: typeof add) {
registerMcpService(operation, {
current,
execute: async input => {
const retry = alertsOutboxRetrySchema.parse({ expectedVersion: input.expectedVersion, acknowledgedDuplicateRisk: input.acknowledgedDuplicateRisk })
try {
return await retryUnknownOutbox(String(input.id), retry, async source => {
if (typeof input.sourceProof !== "string" || await sourceProof(source) !== input.sourceProof) throw new McpError("STATE_CONFLICT", "Запись уведомления изменилась после подтверждения плана", 409)
if (JSON.stringify(await dependencyIds()) !== JSON.stringify(input.serverIds)) throw new McpError("STATE_CONFLICT", "Каталог серверов изменился после подтверждения плана", 409)
})
} catch (error) {
if (error instanceof OutboxRetryConflict) throw new McpError("STATE_CONFLICT", error.message, 409)
if (error instanceof OutboxRetrySourceUnavailable) throw new McpError("STATE_UNAVAILABLE", error.message, 409)
throw error
}
},
verify: async (input, result) => {
const receipt = alertsOutboxRetryReceiptSchema.safeParse(result)
return receipt.success && receipt.data.id === input.id && receipt.data.resolutionVersion === Number(input.expectedVersion) + 1
},
})
register(operation, "mm_alerts_outbox_retry_plan", "Повтор уведомления с неизвестным результатом", "alerts", "mm:alerts:write", "write", "global", schema, {
prepare: async (input, context) => {
const before = await current({ ...input, serverIds: context.connection.policy.scope.serverIds })
if (!before.value) throw new McpError("NOT_FOUND", "Запись очереди не найдена", 404)
if (before.value.channel !== "telegram" || before.value.status !== "unknown" || before.value.resolutionVersion !== input.expectedVersion) throw new McpError("STATE_CONFLICT", "Обновите очередь: повтор разрешён только для unknown с текущей версией", 409)
if (before.serverIds.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная очередь требует доступа ко всему каталогу серверов", 403)
return { target: { scope: "global" }, objects: [{ kind: "alerts-outbox", id: String(input.id) }, ...before.serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId }))],
snapshots: [], before, steps: [{ kind: "service", service: operation, input: { ...input, sourceProof: before.value.proof, serverIds: before.serverIds } }], requiresHuman: true,
verificationScope: "acknowledgement", warnings: ["Предыдущее уведомление могло быть доставлено. Повтор может создать дубликат у получателя; требуется отдельное подтверждение человеком.", "Подтверждается только повторная постановка в очередь. Фоновый отправитель может отправить сообщение после выполнения плана; доставка получателю не подтверждается.", "Изменение записи или каталога серверов инвалидирует план. Текст и адресат остаются в исходной записи и не передаются нейросети."] }
},
})
}
@@ -0,0 +1,282 @@
import { z } from "zod"
import { geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
import { pingProbeWriteSchema, speedProbeWriteSchema, type PingProbeWrite } from "@mmapp/contracts"
import { serverCreateSchema, serverUpdateSchema } from "@mmapp/contracts/servers"
import { putRulesSchema } from "@mmapp/contracts/alerts"
import { statisticsQuerySchema, statisticsPivotQuerySchema } from "@mmapp/contracts/statistics"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import { readBackupJob } from "../../services/backup-job-store.js"
import { projectScopedBackupJob } from "../../services/backup-job-read.js"
import { registerBackupStorageActions } from "./backup-storage-actions.js"
import { pool } from "../../db/index.js"
import { createServer, updateServer, deleteServer, listServerSnapshots } from "../servers/service/servers-service.js"
import { resolveRosSrcIpv4 } from "../../utils/ros-src-address.js"
import { getServerRowById } from "../servers/repository/servers-repository.js"
import { getAlertsOverview, replaceAlertsConfig } from "../../services/alerts-service.js"
import { getSchedulerStatus, JOB_KEYS, listSchedulerRuns, refreshScheduler, runSchedulerJobNow } from "../../services/scheduler.js"
import { getBackupById, getBackupScheduleSettings, getBackupStorageSettings, runBackupForServer, restoreBackupToDevice, deleteBackupRecord, updateBackupScheduleSettings, testBackupStorageConnection, syncBackupsFromS3 } from "../../services/backup-service.js"
import { getCertificateRenewSettings, updateCertificateRenewSettings, getAcmeSettingsPublic } from "../../services/certificates-service.js"
import { getAlertsOutboxOverview } from "../../services/alert-engine/outbox.js"
import { readEvents } from "../events/service/events-service.js"
import { buildFlowAnalytics, getFlowMonthly } from "../../services/traffic-flow-analytics.js"
import { getTrafficSettings, updateTrafficSettings } from "../../services/traffic-collector.js"
import { readUptimeSettings, updateUptimeSettings, readProbeRows, replaceProbes } from "../../services/uptime-collector.js"
import { readSpeedProbeCatalog, replaceSpeedProbeCatalog } from "../../services/speed-probe-catalog.js"
import { getTrafficFlowSettingsRow, toTrafficFlowSettingsDto } from "../../services/traffic-flow-settings.js"
import { getFlowListenerState } from "../../services/traffic-flow-ingest.js"
import { withFlowConfigurationLock, getFlowHostStatus, reconcileFlowHost } from "../../services/traffic-flow-host-agent.js"
import { agentSocket } from "../../services/traffic-flow-agent-client.js"
import { getStatistics, getStatisticsPivot, pivotDimsConflict } from "../../services/statistics-aggregate.js"
import { getServersApiPingSettings, updateServersApiPingSettings } from "../../services/servers-rest-ping-collector.js"
import { getInternetPathSettings, updateInternetPathSettings } from "../../services/internet-path-collector.js"
import { getGeoipSettings, updateGeoipSettings } from "../../services/geoip-settings.js"
import { buildGeoipStatus } from "../../services/geoip-status.js"
import { publicEvoBgpSettings, readEvoBgpSettings } from "../../services/evobgp-settings.js"
import { readEvoBgpCatalogPage } from "./evobgp-catalog.js"
import { readWanRuntimeForServer } from "../../services/wan-runtime.js"
import { readTrafficConfiguration, readUptimeConfiguration, readServersApiPingConfiguration } from "./telemetry-settings-configuration.js"
import { registerMcpService } from "./service-registry.js"
import { decryptSecret, fingerprint, McpError } from "./security.js"
import type { add } from "./actions.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
const sid = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const paging = { limit: z.number().int().min(1).max(200).default(100), offset: z.number().int().min(0).max(100000).default(0) }
async function serverSecret(input: Record<string, unknown>, context: ActionContext) {
const config = input.server as Record<string, unknown>, reference = input.passwordRef
if (!reference) return config
const { rows: [secret] } = await pool.query("SELECT * FROM mcp_secrets WHERE id=$1 AND created_by=$2", [reference, context.connection.owner_id])
const existing = input.serverId ? await getServerRowById(Number(input.serverId)) : undefined
const host = String(config.host ?? existing?.host ?? ""), port = Number(config.port ?? existing?.port ?? 443)
if (!secret || secret.purpose !== `server-login:${host}:${port}` || secret.server_id && Number(secret.server_id) !== input.serverId) throw new McpError("SECRET_UNAVAILABLE", "Секрет не выдан этому назначению")
return { ...config, password: await decryptSecret(secret.ciphertext) }
}
export function registerApplicationActions(register: typeof add) {
registerBackupStorageActions(register)
register("servers.wan-runtime.read", "mm_server_wan_runtime_get", "WAN IPv4/main: шлюз, интерфейс и адреса без доказательства forwarding", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
read: async input => {
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const result = await readWanRuntimeForServer(server), offset = Number(input.offset), limit = Number(input.limit)
const uplinks = result.uplinks.slice(offset, offset + limit).map(uplink => ({ ...uplink, addresses: uplink.addresses.slice(0, 100), addressesTruncated: uplink.addresses.length > 100 }))
return { ...result, serverId: server.id, observedAt: new Date().toISOString(), uplinks, totalUplinks: result.uplinks.length, nextOffset: offset + limit < result.uplinks.length ? offset + limit : null,
incomplete: result.activeDefaultsTruncated || uplinks.length < result.uplinks.length || uplinks.some(uplink => uplink.addressesTruncated) }
},
})
register("servers.snapshots.read", "mm_server_snapshots_get", "История состояния выбранного сервера", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid, limit: z.number().int().min(1).max(200).default(50) }).strict(), {
read: async input => {
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const snapshots = await listServerSnapshots(server.id, Number(input.limit))
return { serverId: server.id, snapshots, incomplete: snapshots.length === Number(input.limit) }
},
})
register("servers.source-address.read", "mm_server_source_address_get", "IPv4 адрес управляющего host, без подтверждения адреса интерфейса", "servers", "mm:servers:read", "read", "servers", z.object({ serverId: sid }).strict(), {
read: async input => {
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
return { host: server.host, ipv4: await resolveRosSrcIpv4(server.host), purpose: "management-host-resolution", interfaceAddressVerified: false }
},
})
function query(id: string, tool: string, title: string, permission: string, read: NonNullable<Parameters<typeof register>[8]["read"]>, schema = z.object({}).strict()) {
register(id, tool, title, permission.split(":")[1], permission, "read", "global", schema, { read: async (input, context) => {
const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id))
const { rows: grants } = await pool.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [context.connection.id, context.principal.credentialId])
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id) || grants.some(grant => !grant.payload.scope?.serverIds?.includes(id)))) throw new McpError("SERVER_DENIED", "Глобальный обзор содержит серверы вне эффективной области доступа")
return read(input, context)
} })
}
function change(id: string, tool: string, title: string, permission: string, schema: z.ZodObject,
current: (input: Record<string, unknown>) => Promise<unknown>, execute: (input: Record<string, unknown>) => Promise<unknown>, serverScoped = false) {
const dependencyIds = async () => (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
const observed = async (input: Record<string, unknown>) => serverScoped ? current(input) : { value: await current(input), serverIds: await dependencyIds() }
const verifiedConfiguration = ["alerts.replace", "backups.delete", "uptime.ping.probes.replace", "uptime.speed.probes.replace", "netflow.host.reconcile"].includes(id) || id.endsWith(".update")
registerMcpService(id, { current: observed, execute, verify: async (input, result) => {
if (input.settings && typeof input.settings === "object") {
const saved = await current(input) as Record<string, unknown>
return !!saved && Object.entries(input.settings).every(([key, value]) => fingerprint(saved[key]) === fingerprint(value))
}
if (id === "alerts.replace") return !!result && fingerprint(await current(input)) === fingerprint({ revision: (result as { revision: unknown }).revision })
if (id === "backups.delete") return !await getBackupById(String(input.backupId))
if (id === "netflow.host.reconcile") {
const status = await getFlowHostStatus()
return status.reachable && status.interfaceUp && status.configMatches === true
}
if (Array.isArray(input.probes)) {
const saved = await current(input) as Record<string, unknown>[]
return saved.length === input.probes.length && input.probes.every((probe, index) => Object.entries(probe).every(([key, value]) => fingerprint(saved[index][key]) === fingerprint(key === "intervalSec" && Number(value) > 0 ? Math.max(5, Number(value)) : value)))
}
return result !== undefined
} })
register(id, tool, title, permission.split(":")[1], permission, "write", serverScoped ? "servers" : "global", schema, {
...(["traffic.settings.update", "uptime.settings.update", "servers.api-ping.settings.update"].includes(id) ? { version: 2 } : {}),
prepare: async (input, context) => {
const serverId = typeof input.serverId === "number" ? input.serverId : undefined
if (serverId && !context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
const before = await observed(input), ids = serverScoped ? [serverId!] : (before as { serverIds: number[] }).serverIds
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка затрагивает серверы вне выданной области")
const objects: ChangeEvidence["objects"] = ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))
const domain = ({ "traffic.settings.update": "traffic-settings", "uptime.settings.update": "uptime-settings", "alerts.replace": "alerts-configuration", "backups.schedule.update": "backup-schedule", "certificates.renew.update": "certificate-renewal", "netflow.host.reconcile": "netflow-host", "servers.api-ping.settings.update": "servers-api-ping-settings", "internet-path.settings.update": "internet-path-settings" } as Record<string, string>)[id]
if (domain) objects.push({ kind: domain, id: "1" })
if (id === "geoip.settings.update") objects.push({ kind: "geoip-settings", id: "1" })
if (Array.isArray(input.probes)) {
if (input.probes.some(probe => [probe.srcServerId, probe.dstServerId].filter(value => value !== undefined).some(value => !ids.includes(Number(value))))) throw new McpError("SERVER_DENIED", "Проба ссылается на неизвестный или недоступный сервер")
const old = (before as { value: { id: string }[] }).value
objects.push(...[...new Set([...old.map(probe => probe.id), ...input.probes.map(probe => String(probe.id))])].map(probeId => ({ kind: id.includes(".ping.") ? "ping-probe" : "speed-probe", id: probeId })))
}
return { target: serverScoped ? { scope: "servers", serverIds: [serverId!] } : { scope: "global" }, objects, snapshots: [], before, steps: [{ kind: "service", service: id, input }], verificationScope: verifiedConfiguration ? "configuration" : "acknowledgement", warnings: [...(id === "geoip.settings.update" ? ["При включённом GeoIP обновление расписания запускает проверку зеркала сразу и затем по интервалу. Подтверждается сохранение настроек; доставка и готовность баз проверяются отдельно через состояние GeoIP. Отключение не отменяет уже начатую загрузку."] : []), ...(!verifiedConfiguration ? ["Подтверждается приём/завершение команды сервисом. Итоговое состояние внешней системы требует отдельной проверки."] : []), ...(Array.isArray(input.probes) ? ["Заменяется весь каталог проб, включая удаление отсутствующих ID. Включённые bandwidth-пробы создают повторяющуюся сетевую нагрузку по расписанию."] : []), ...(serverScoped ? [] : ["Глобальная операция затрагивает всё приложение. Проверен полный текущий каталог серверов; его изменение инвалидирует план."])], requiresHuman: true }
},
})
}
for (const action of ["create", "update", "delete"] as const) {
const schema = action === "delete" ? z.object({ serverId: sid }).strict() : z.object({ ...(action === "update" ? { serverId: sid } : {}), server: (action === "create" ? serverCreateSchema.omit({ password: true }) : serverUpdateSchema.omit({ password: true })).strict(), passwordRef: z.string().uuid().optional() }).strict()
const id = `servers.${action}`
registerMcpService(id, {
current: async input => input.serverId ? getServerRowById(Number(input.serverId)) : null,
execute: async input => {
if (action === "delete") { await deleteServer(Number(input.serverId)); return { deleted: true } }
const config = action === "create" ? serverCreateSchema.parse(input.resolvedServer) : serverUpdateSchema.parse(input.resolvedServer)
return action === "create" ? createServer(config as z.infer<typeof serverCreateSchema>) : updateServer(Number(input.serverId), config)
},
verify: async (input, result) => {
if (action === "delete") return !await getServerRowById(Number(input.serverId))
const id = action === "create" ? Number((result as { id?: number } | undefined)?.id) : Number(input.serverId)
const saved = await getServerRowById(id)
if (!saved) return false
const expected = action === "create" ? serverCreateSchema.parse(input.resolvedServer) : serverUpdateSchema.parse(input.resolvedServer)
return Object.entries(expected).every(([key, value]) => fingerprint(saved[key as keyof typeof saved]) === fingerprint(value))
},
})
register(id, `mm_servers_${action}_plan`, `Серверы: ${action}`, "servers", "mm:servers:write", "write", action === "create" ? "global" : "servers", schema, {
prepare: async (input, context) => {
const before = input.serverId ? await getServerRowById(Number(input.serverId)) : null
if (action !== "create" && !before) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const config = input.server as Record<string, unknown> | undefined
if (action === "update" && ["host", "port", "username", "useSsl", "verifySsl"].some(key => config?.[key] !== undefined && config[key] !== before?.[key as keyof typeof before]) && !input.passwordRef) throw new McpError("SECRET_UNAVAILABLE", "Для изменения параметров подключения нужен секрет, привязанный к назначению")
const resolvedServer = action === "delete" ? undefined : await serverSecret(input, context)
return { target: action === "create" ? { scope: "global" } : { scope: "servers", serverIds: [Number(input.serverId)] }, objects: input.serverId ? [{ kind: "server", id: String(input.serverId), serverId: Number(input.serverId) }] : [], snapshots: [], before,
steps: [{ kind: "service", service: id, input: { ...input, resolvedServer } }], requiresHuman: true, warnings: ["Новый сервер не добавляется автоматически в разрешения MCP."] }
},
})
}
query("alerts.read", "mm_alerts_get", "Оповещения и история", "mm:alerts:read", async () => getAlertsOverview())
query("alerts.outbox.read", "mm_alerts_outbox_get", "Очередь уведомлений", "mm:alerts:read", async () => getAlertsOutboxOverview())
change("alerts.replace", "mm_alerts_update_plan", "Настройка правил оповещений", "mm:alerts:write", z.object({ configuration: putRulesSchema }).strict(),
async () => { const result = await getAlertsOverview(); return { revision: result.revision } },
async input => { const result = await replaceAlertsConfig(putRulesSchema.parse(input.configuration)); await refreshScheduler(); return result })
query("scheduler.read", "mm_scheduler_get", "Состояние планировщика", "mm:settings:admin", async () => getSchedulerStatus())
query("evobgp.settings.read", "mm_evobgp_settings_get", "Публичные настройки интеграции EvoBGP", "mm:settings:admin", async () => publicEvoBgpSettings(await readEvoBgpSettings()))
query("evobgp.catalog.read", "mm_evobgp_catalog_get", "Каталог EvoBGP: модули, ASN, префиксы и Community", "mm:settings:admin", readEvoBgpCatalogPage, z.object(paging).strict())
const trafficSettings = z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(5).max(3600).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0)
const uptimeSettings = z.object({ enabled: z.boolean().optional(), resourcesEnabled: z.boolean().optional(), pingEnabled: z.boolean().optional(), speedEnabled: z.boolean().optional(), intervalSec: z.number().int().min(5).max(86400).optional(), probeIntervalSec: z.number().int().min(5).max(86400).optional(), speedIntervalSec: z.number().int().min(10).max(86400).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0)
query("traffic.settings.read", "mm_traffic_settings_get", "Настройки сбора трафика", "mm:traffic:read", async () => getTrafficSettings())
query("geoip.read", "mm_geoip_get", "Состояние локальных баз GeoIP и обновлений", "mm:traffic:read", async () => buildGeoipStatus())
const geoipSettings = geoipSettingsPatchSchema.strict().refine(value => Object.keys(value).length > 0)
change("geoip.settings.update", "mm_geoip_settings_update_plan", "План настроек обновления GeoIP", "mm:traffic:write", z.object({ settings: geoipSettings }).strict(), async () => {
const settings = await getGeoipSettings()
return { enabled: settings.enabled, updateIntervalSec: settings.updateIntervalSec }
}, async input => {
const settings = await updateGeoipSettings(geoipSettings.parse(input.settings))
await refreshScheduler()
return { settings, verificationScope: "configuration-persistence" }
})
change("traffic.settings.update", "mm_traffic_settings_update_plan", "План настроек сбора трафика", "mm:traffic:write", z.object({ settings: trafficSettings }).strict(), readTrafficConfiguration, async input => { const result = await updateTrafficSettings(trafficSettings.parse(input.settings)); await refreshScheduler(); return result })
query("uptime.settings.read", "mm_uptime_settings_get", "Настройки мониторинга и проб", "mm:uptime:read", async () => readUptimeSettings())
change("uptime.settings.update", "mm_uptime_settings_update_plan", "План расписания мониторинга и проб", "mm:uptime:write", z.object({ settings: uptimeSettings }).strict(), readUptimeConfiguration, async input => { const result = await updateUptimeSettings(uptimeSettings.parse(input.settings)); await refreshScheduler(); return result })
query("netflow.settings.read", "mm_netflow_settings_get", "Публичные настройки NetFlow", "mm:traffic:read", async () => toTrafficFlowSettingsDto(getFlowListenerState()))
query("scheduler.runs", "mm_scheduler_runs_list", "История заданий", "mm:settings:admin", async input => listSchedulerRuns({ limit: Number(input.limit), offset: Number(input.offset), jobKey: input.jobKey as string | undefined }), z.object({ ...paging, jobKey: z.enum(JOB_KEYS).optional() }).strict())
query("servers.api-ping.settings.read", "mm_servers_api_ping_settings_get", "Настройки API-ping серверов", "mm:servers:read", async () => getServersApiPingSettings())
change("servers.api-ping.settings.update", "mm_servers_api_ping_settings_update_plan", "План API-ping серверов", "mm:servers:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(10).max(86400).optional() }).strict().refine(value => Object.keys(value).length > 0) }).strict(), readServersApiPingConfiguration, async input => { const result = await updateServersApiPingSettings(input.settings as Parameters<typeof updateServersApiPingSettings>[0]); await refreshScheduler(); return result })
query("internet-path.settings.read", "mm_internet_path_settings_get", "Настройки наблюдений Internet path", "mm:network:read", async () => getInternetPathSettings())
const readInternetPathConfiguration = async () => {
const row = await getInternetPathSettings()
// Collection also updates updatedAt: bind plans to configuration, not run status.
return { enabled: row.enabled, intervalSec: row.intervalSec, retentionDays: row.retentionDays }
}
change("internet-path.settings.update", "mm_internet_path_settings_update_plan", "План сбора Internet path", "mm:network:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(30).max(86400).optional(), retentionDays: z.number().int().min(1).max(365).optional() }).strict().refine(value => Object.keys(value).length > 0) }).strict(), readInternetPathConfiguration, async input => { const result = await updateInternetPathSettings(input.settings as Parameters<typeof updateInternetPathSettings>[0]); await refreshScheduler(); return result })
query("netflow.host.read", "mm_netflow_host_get", "Состояние хоста NetFlow", "mm:settings:admin", async () => getFlowHostStatus())
change("netflow.host.reconcile", "mm_netflow_host_reconcile_plan", "План применения сохранённой конфигурации хоста NetFlow", "mm:settings:admin", z.object({}).strict(), async () => {
if (!agentSocket()) throw new McpError("UNSUPPORTED_CAPABILITY", "Локальный агент NetFlow не настроен", 409)
const row = await getTrafficFlowSettingsRow()
return { configurationHash: fingerprint({ collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort, hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey, peersJson: row.peersJson }) }
}, async () => withFlowConfigurationLock(() => reconcileFlowHost()))
const pingCatalog = async () => (await readProbeRows()).map(row => ({ id: row.id, srcServerId: row.srcServerId, srcInterface: row.srcInterface, name: row.name, target: row.target, filter: row.probeFilter, enabled: row.enabled, showOnDashboard: row.showOnDashboard, intervalSec: row.intervalSec }))
const speedCatalog = async () => (await readSpeedProbeCatalog()).map(row => ({ id: row.id, srcServerId: row.srcServerId, dstServerId: row.dstServerId, srcInterface: row.srcInterface, dstInterface: row.dstInterface, protocol: row.protocol, direction: row.direction, durationSec: row.durationSec, enabled: row.enabled }))
query("uptime.ping.probes.read", "mm_ping_probes_get", "Каталог Ping-проб", "mm:uptime:read", pingCatalog)
query("uptime.speed.probes.read", "mm_speed_probes_get", "Каталог bandwidth-проб", "mm:uptime:read", speedCatalog)
const boundedCatalog = (item: z.ZodObject) => z.object({ probes: z.array(item.strict()).max(500).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID пробы") }).strict()
change("uptime.ping.probes.replace", "mm_ping_probes_replace_plan", "План полного каталога Ping-проб", "mm:uptime:write", boundedCatalog(pingProbeWriteSchema), pingCatalog, async input => { await replaceProbes(input.probes as PingProbeWrite[]); await refreshScheduler(); return { saved: true } })
change("uptime.speed.probes.replace", "mm_speed_probes_replace_plan", "План полного каталога bandwidth-проб", "mm:uptime:write", boundedCatalog(speedProbeWriteSchema.safeExtend({ durationSec: z.number().int().min(3).max(30) })), speedCatalog, async input => { await replaceSpeedProbeCatalog(input.probes as Parameters<typeof replaceSpeedProbeCatalog>[0]); await refreshScheduler(); return { saved: true } })
change("scheduler.run", "mm_scheduler_run_plan", "Запуск задания", "mm:settings:admin", z.object({ jobKey: z.enum(JOB_KEYS) }).strict(), async () => null, async input => runSchedulerJobNow(String(input.jobKey)))
change("scheduler.refresh", "mm_scheduler_refresh_plan", "Перечитать расписания", "mm:settings:admin", z.object({}).strict(), async () => null, async () => { await refreshScheduler(); return { refreshed: true } })
query("events.read", "mm_events_list", "События приложения", "mm:dashboard:read", async input => ({ events: await readEvents({ limit: Number(input.limit) }) }), z.object({ limit: z.number().int().min(1).max(200).default(100) }).strict())
register("backups.jobs.read", "mm_backup_job_get", "Состояние фоновой backup-задачи приложения", "backups", "mm:backups:read", "read", "servers", z.object({ jobId: z.string().min(1).max(200), ...paging }).strict(), {
read: async (input, context) => {
let result: ReturnType<typeof projectScopedBackupJob>
try { result = projectScopedBackupJob(readBackupJob(String(input.jobId)), new Set(context.connection.policy.scope.serverIds), Number(input.offset), Number(input.limit)) }
catch { throw new McpError("STATE_UNAVAILABLE", "Прогресс backup-задачи не подтверждён", 503) }
if (!result) throw new McpError("NOT_FOUND", "Backup-задача не найдена в разрешённой области", 404)
const rows = await pool.query("SELECT id FROM servers WHERE id=ANY($1::bigint[])", [result.serverIds])
if (rows.rows.length !== result.serverIds.length) throw new McpError("NOT_FOUND", "Устройства backup-задачи отсутствуют в каталоге", 404)
return result
},
})
register("backups.list", "mm_backups_list", "Резервные копии", "backups", "mm:backups:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
read: async input => {
const { rows } = await pool.query<{ id: string }>("SELECT id FROM backup_entries WHERE server_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", [String(input.serverId), input.limit, input.offset])
return { items: await Promise.all(rows.map(row => getBackupById(row.id))) }
},
})
change("backups.create", "mm_backups_create_plan", "Создание backup", "mm:backups:write", z.object({ serverId: sid, notes: z.string().max(1000).default("") }).strict(), async input => getServerRowById(Number(input.serverId)), async input => runBackupForServer(String(input.serverId), "manual", String(input.notes)), true)
for (const action of ["restore", "delete"] as const) {
change(`backups.${action}`, `mm_backups_${action}_plan`, `Backup: ${action}`, "mm:backups:write", z.object({ serverId: sid, backupId: z.string().min(1).max(200) }).strict(), async input => {
const backup = await getBackupById(String(input.backupId)); if (!backup || backup.serverId !== input.serverId) throw new McpError("NOT_FOUND", "Резервная копия не найдена", 404); return backup
}, async input => action === "restore" ? restoreBackupToDevice(String(input.backupId)) : deleteBackupRecord(String(input.backupId)), true)
}
query("backups.schedule.read", "mm_backup_schedule_get", "Расписание backup", "mm:backups:read", async () => getBackupScheduleSettings())
query("backups.storage.read", "mm_backup_storage_get", "Состояние хранилища backup", "mm:backups:read", async () => getBackupStorageSettings())
change("backups.schedule.update", "mm_backup_schedule_update_plan", "Изменение расписания backup", "mm:backups:write", z.object({ settings: putBackupScheduleSettingsSchema }).strict(), async () => getBackupScheduleSettings(), async input => { const result = await updateBackupScheduleSettings(putBackupScheduleSettingsSchema.parse(input.settings)); await refreshScheduler(); return result })
change("backups.storage.test", "mm_backup_storage_test_plan", "Проверка хранилища backup", "mm:backups:write", z.object({}).strict(), async () => getBackupStorageSettings(), async () => testBackupStorageConnection())
change("backups.storage.sync", "mm_backup_storage_sync_plan", "Синхронизация backup из S3", "mm:backups:write", z.object({}).strict(), async () => null, async () => syncBackupsFromS3())
query("certificates.acme.read", "mm_acme_settings_get", "Публичные настройки ACME", "mm:certificates:read", async () => getAcmeSettingsPublic())
query("certificates.renew.read", "mm_certificate_renew_get", "Настройки продления сертификатов", "mm:certificates:read", async () => getCertificateRenewSettings())
change("certificates.renew.update", "mm_certificate_renew_update_plan", "Настройка продления сертификатов", "mm:certificates:write", z.object({ settings: z.object({ enabled: z.boolean().optional(), intervalSec: z.number().int().min(60).max(86400).optional(), renewBeforeDays: z.number().int().min(1).max(365).optional() }).strict() }).strict(), async () => getCertificateRenewSettings(), async input => updateCertificateRenewSettings(input.settings as Parameters<typeof updateCertificateRenewSettings>[0]))
for (const table of ["traffic_samples", "uptime_resource_samples", "uptime_probe_samples"] as const) {
register(`monitoring.${table}.read`, `mm_${table}_query`, `История: ${table}`, "monitoring", table === "traffic_samples" ? "mm:traffic:read" : "mm:uptime:read", "read", "servers", z.object({ serverId: sid, ...paging }).strict(), {
read: async input => {
if (table === "uptime_probe_samples") {
const { rows } = await pool.query("SELECT s.* FROM uptime_probe_samples s JOIN uptime_probes p ON p.id=s.probe_id WHERE p.src_server_id=$1 ORDER BY sampled_at DESC LIMIT $2 OFFSET $3", [input.serverId, input.limit, input.offset]); return { items: rows }
}
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE server_id=$1 ORDER BY sampled_at DESC LIMIT $2 OFFSET $3`, [input.serverId, input.limit, input.offset]); return { items: rows }
},
})
}
register("monitoring.flow.analytics", "mm_netflow_analytics_get", "NetFlow выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({ serverId: sid, minutes: z.number().int().min(1).max(1440).default(15), interface: z.string().min(1).max(100).optional() }).strict(), {
read: async input => {
const result = await buildFlowAnalytics({ serverId: Number(input.serverId), minutes: Number(input.minutes), iface: input.interface as string | undefined, skipHeavy: true })
return { serverId: input.serverId, source: "PostgreSQL/flow_buckets", observedAt: new Date().toISOString(), bpsNow: result.bpsNow, bytes: result.bytes, packets: result.packets, conversations: result.conversations, uniqueSrc: result.uniqueSrc, uniqueDst: result.uniqueDst, rxSeries: result.rxSeries, txSeries: result.txSeries, applications: result.applications, protocols: result.protocols, countries: result.countries, categories: result.categories, services: result.services,
sources: result.sources.map(row => ({ ...row, label: row.id })), destinations: result.destinations.map(row => ({ ...row, label: row.id })) }
},
})
register("monitoring.flow.monthly", "mm_netflow_monthly_get", "Месячная статистика NetFlow выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({ serverId: sid, month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/) }).strict(), {
read: async input => ({ serverId: input.serverId, source: "PostgreSQL/flow_daily_dims", observedAt: new Date().toISOString(), statistics: await getFlowMonthly(String(input.month), Number(input.serverId), { maxKeys: 10000 }) }),
})
const period = { serverId: sid, from: z.string().date(), to: z.string().date() }
for (const pivot of [false, true]) {
const schema = (pivot ? statisticsPivotQuerySchema : statisticsQuerySchema).extend(period).strict().superRefine((input, context) => {
const span = Date.parse(input.to) - Date.parse(input.from)
if (span < 0 || span > 30 * 86400000) context.addIssue({ code: "custom", message: "Статистика MCP ограничена 31 календарным днём" })
})
register(pivot ? "statistics.pivot" : "statistics.read", pivot ? "mm_statistics_pivot_get" : "mm_statistics_get", pivot ? "Сводная статистика выбранного сервера" : "Статистика выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", schema, {
read: async input => {
if (!pivot) return getStatistics(statisticsQuerySchema.parse(input))
const parsed = statisticsPivotQuerySchema.parse(input)
if (pivotDimsConflict(parsed.row, parsed.col)) throw new McpError("INVALID_INPUT", "Строки и столбцы сводной должны отличаться", 400)
return getStatisticsPivot(parsed)
},
})
}
}
@@ -0,0 +1,36 @@
import assert from "node:assert/strict"
process.env.AUTH_REQUIRED="true"
process.env.AUTH_JWT_SECRET="mcp-owned-application-acceptance-secret"
process.env.AUTH_ISSUER="https://auth.test.local"
const {pool,closePool}=await import("../../db/index.js")
const {applySqlMigrations}=await import("../../db/migrate.js")
const {updateMcpSettings}=await import("./store.js")
const {buildApp}=await import("../../index.js")
const actor={id:"application-acceptance-owner",apps:["mm"],permissions:["mm:settings:admin","mm:network:admin"],expiresAt:Date.now()+3600000}
let app:Awaited<ReturnType<typeof buildApp>>|undefined
try{
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0,"Disposable empty database required")
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,enabled) VALUES(1,'owned-fixture','127.0.0.1',9,false,false)")
await pool.query("INSERT INTO geoip_settings(id,enabled) VALUES(1,false) ON CONFLICT(id) DO UPDATE SET enabled=false")
await pool.query("INSERT INTO traffic_flow_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
await updateMcpSettings({enabled:true},actor)
app=await buildApp({startScheduler:false});await app.ready()
await (await import("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
await (await import("../../test/mcp-geoip-update.js")).verifyMcpGeoipUpdate(actor)
await (await import("../../test/mcp-flow-overlay.js")).verifyMcpFlowOverlay(actor)
await (await import("../../test/mcp-backup-download.js")).verifyMcpBackupDownload(actor,app)
await (await import("./wireguard-interface-create-acceptance.js")).acceptWireguardInterfaceCreation(actor)
await (await import("./wireguard-peer-create-acceptance.js")).acceptWireguardPeerCreation(actor)
await (await import("../../test/mcp-database-backup.js")).verifyMcpDatabaseBackup(actor,app)
}finally{
await pool.query("UPDATE servers SET enabled=false").catch(()=>{})
// Database export resumes shared background work even for this scheduler-free
// app fixture. Drain it before closing the pool, as the full integration does.
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
await (await import("../../services/traffic-flow-ingest.js")).stopTrafficFlowListenerForDatabaseOperation()
await app?.close();await closePool()
}
@@ -0,0 +1,19 @@
import { hasPermission } from "../../lib/permissions.js"
interface RegistrationPolicy {
policy: { scope: { operationIds: readonly string[] }; permissions: readonly string[]; mode: string }
owner_permissions: readonly string[]
}
interface RegisteredAction { operation: { id: string; requiredPermissions: readonly string[]; effect: string } }
/** Exact existing server-registration policy. Priority and SDK registration
* consume the SAME returned action objects; this does not replace runtime
* revalidation or manufacture effective/temporary-grant authorization.
*/
export function authorizedMcpBindings<T extends RegisteredAction>(actions: readonly T[], connection: RegistrationPolicy): T[] {
return actions.filter(action => {
const descriptor = action.operation
return connection.policy.scope.operationIds.includes(descriptor.id) &&
descriptor.requiredPermissions.every(permission => hasPermission(connection.policy.permissions, permission) && hasPermission(connection.owner_permissions, permission)) &&
(connection.policy.mode !== "observer" || descriptor.effect === "read")
})
}
@@ -0,0 +1,46 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { accessGeneration, fingerprint, McpError } from "./security.js"
import { effectiveMcpConnection } from "./store.js"
import { registerMcpService } from "./service-registry.js"
import { createBackupDownloadArtifact, backupDownloadAuthority, backupDownloadAuthoritySchema, readBackupDownloadSource, verifyBackupDownloadArtifact } from "./backup-download-artifact.js"
import type { add } from "./actions.js"
export function registerBackupDownloadActions(register: typeof add) {
const authority=(input:Record<string,unknown>)=>backupDownloadAuthoritySchema.parse(input.authority)
const current=async(input:Record<string,unknown>)=>({proof:(await readBackupDownloadSource(authority(input))).proof})
registerMcpService("backups.download",{current,
execute:async input=>createBackupDownloadArtifact(authority(input),String(input.expectedProof)),
verify:async(input,result)=>{
const receipt=result as {artifactId?:string;humanDownloadOnly?:boolean;backupContentReturned?:boolean}|null
return receipt?.humanDownloadOnly===true && receipt.backupContentReturned===false && !!receipt.artifactId && await verifyBackupDownloadArtifact(receipt.artifactId,authority(input),String(input.expectedProof))
},
})
register("backups.download","mm_backup_download_plan","Подготовить владельцу скачивание полной резервной копии после человеческого подтверждения","backups","mm:backups:write","write","servers",z.object({backupId:z.string().min(1).max(200)}).strict(),{
target:async(input,context)=>{
const row=(await (context.transactionClient??pool).query("SELECT server_id FROM backup_entries WHERE id=$1",[input.backupId])).rows[0]
if(!row?.server_id)throw new McpError("SERVER_DENIED","Принадлежность резервной копии не подтверждена",403)
return {scope:"servers",serverIds:[Number(row.server_id)]}
},
prepare:async(input,context)=>{
const authorize=async()=>{
if(context.signal?.aborted)throw new McpError("READ_CANCELLED","Подготовка скачивания отменена",409)
const fresh=await effectiveMcpConnection(context.principal)
if(fingerprint(fresh.policy)!==fingerprint(context.connection.policy))throw new McpError("RESET_REQUIRED","Допуск изменился; подготовьте скачивание заново",409)
}
await authorize()
const row=(await pool.query("SELECT server_id FROM backup_entries WHERE id=$1",[input.backupId])).rows[0]
if(!row?.server_id)throw new McpError("SERVER_DENIED","Принадлежность резервной копии не подтверждена",403)
const access=backupDownloadAuthority(context.principal,context.connection.owner_id,context.connection.policy,await accessGeneration(),String(input.backupId),Number(row.server_id))
const state=await readBackupDownloadSource(access,authorize,context.signal)
const objects=[{kind:"backup-download",id:String(input.backupId),serverId:access.serverId},{kind:"backup",id:String(input.backupId),serverId:access.serverId},{kind:"server",id:String(access.serverId),serverId:access.serverId}]
const protections=await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001",[String(access.serverId)])
if(protections.rows.length>1000)throw new McpError("STATE_UNAVAILABLE","Превышен предел защит резервной копии",409)
for(const {object_ref:ref} of protections.rows)objects.push(ref)
await authorize()
return {target:{scope:"servers",serverIds:[access.serverId]},objects,snapshots:[],before:{proof:state.proof},requiresHuman:true,mutationCost:1,
steps:[{kind:"service",service:"backups.download",input:{authority:access,expectedProof:state.proof,beforeFingerprint:fingerprint({proof:state.proof})}}],
warnings:["Полная резервная копия может содержать пароли, приватные ключи и закрытую конфигурацию RouterOS. Подтверждение разрешает раскрытие только вошедшему владельцу через web API на 15 минут.","Модель получает только ссылку и контрольную сумму; содержимое резервной копии в MCP не возвращается. Файл ограничен 8 МиБ, чтение — 45 секундами.","Отзыв, пауза, смена прав, владельца, поколения доступа, metadata, содержимого или подключения S3 блокируют выдачу. Для нового экспорта требуется новый человеческий план; автоматического повтора нет."]}
},
})
}
@@ -0,0 +1,143 @@
import { createHash, randomUUID, timingSafeEqual } from "node:crypto"
import { z } from "zod"
import type { PoolClient } from "pg"
import { pool } from "../../db/index.js"
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
import { readBackupContent, type BackupMeta } from "../../services/backup-service.js"
import { assertVerifiedConfigurationAccess, executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
import { assertLogArtifactBudget } from "./log-artifacts.js"
import { assertPortalClaimsCurrent } from "./portal-revocation.js"
import { effectiveMcpConnection, transaction, type McpPrincipal } from "./store.js"
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint } from "./security.js"
const MAX_BYTES = 8 * 1048576, MAX_CIPHER = 16 * 1048576
const digest = z.string().regex(/^[a-f0-9]{64}$/)
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.iso.datetime() }).strict()
export const backupDownloadAuthoritySchema = z.object({ principal: principalSchema, ownerId: z.string().min(1).max(200), policyProof: digest, generation: digest, backupId: z.string().min(1).max(200), serverId: z.number().int().positive().safe() }).strict()
export type BackupDownloadAuthority = z.infer<typeof backupDownloadAuthoritySchema>
const scopeSchema = z.object({ version: z.literal(1), kind: z.literal("backup-download"), authority: backupDownloadAuthoritySchema, sourceProof: digest,
bytes: z.number().int().min(0).max(MAX_BYTES), sha256: digest, encryptedSha256: digest, proof: digest }).strict()
type Header = { id: string; connection_id: string; filename: string; media_type: string; expires_at: Date }
type Meta = Header & { access_scope: z.infer<typeof scopeSchema>; encrypted_bytes: number }
const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex")
const sign = (value: unknown) => privateSecretFingerprint(fingerprint(value), "backup-download-artifact-v1")
const signed = (header: Header, scope: Omit<z.infer<typeof scopeSchema>, "proof">) => ({ id:header.id,connection_id:header.connection_id,filename:header.filename,media_type:header.media_type,expires_at:header.expires_at.toISOString(),scope })
const fail = (message: string, code = "ARTIFACT_INVALID") => new McpError(code, message, 409)
export async function assertBackupDownloadAccess(raw: unknown, client: Pick<PoolClient, "query"> = pool) {
const authority = backupDownloadAuthoritySchema.parse(raw), connection = await effectiveMcpConnection(authority.principal, client)
if (connection.owner_id !== authority.ownerId || !hasPermission(connection.owner_permissions,"mm:backups:write") || !hasPermission(connection.policy.permissions,"mm:backups:write") || !connection.policy.scope.operationIds.includes("backups.download") || !connection.policy.scope.serverIds.includes(authority.serverId)) throw new McpError("PERMISSION_DENIED","Нет допуска к полной резервной копии",403)
if (fingerprint(connection.policy) !== authority.policyProof || await accessGeneration() !== authority.generation) throw fail("Допуск изменился; подготовьте скачивание заново","RESET_REQUIRED")
const row = (await client.query("SELECT server_id FROM backup_entries WHERE id=$1 AND EXISTS(SELECT 1 FROM servers WHERE id=backup_entries.server_id)",[authority.backupId])).rows[0]
if (!row || Number(row.server_id)!==authority.serverId) throw fail("Принадлежность резервной копии не подтверждена","SERVER_DENIED")
const denied=(await client.query("SELECT 1 FROM mcp_protections WHERE object_ref->>'protection'='deny' AND (object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) OR (object_ref->>'kind' IN ('backup','backup-download') AND object_ref->>'id'=$2)) LIMIT 1",[String(authority.serverId),authority.backupId])).rows[0]
if(denied)throw fail("Резервная копия заблокирована защитой объекта","PROTECTED_OBJECT")
return { connection, serverIds: [authority.serverId] }
}
async function sourceMetadata(authority: BackupDownloadAuthority) {
const row=(await pool.query(`SELECT CASE WHEN octet_length(to_jsonb(b)::text)<=32768 THEN to_jsonb(b) ELSE NULL END AS metadata,b.xmin::text AS revision FROM backup_entries b WHERE id=$1`,[authority.backupId])).rows[0]
const storage=(await pool.query(`SELECT CASE WHEN octet_length(to_jsonb(s)::text)<=32768 THEN to_jsonb(s) ELSE NULL END AS metadata,s.xmin::text AS revision FROM backup_storage_settings s WHERE id=1`)).rows[0]
const protections=(await pool.query("SELECT CASE WHEN octet_length(object_ref::text)<=32768 THEN object_ref ELSE NULL END AS object_ref,xmin::text AS revision FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) OR (object_ref->>'kind' IN ('backup','backup-download') AND object_ref->>'id'=$2) ORDER BY object_ref::text LIMIT 1001",[String(authority.serverId),authority.backupId])).rows
if(!row?.metadata || (storage && !storage.metadata)) throw fail("Источник резервной копии превышает предел или отсутствует","STATE_UNAVAILABLE")
if(protections.length>1000 || protections.some(row=>!row.object_ref))throw fail("Защиты резервной копии превышают предел","STATE_UNAVAILABLE")
return {backup:row.metadata,backupRevision:row.revision,storage:storage?.metadata??null,storageRevision:storage?.revision??null,protections}
}
export async function readBackupDownloadSource(authority: BackupDownloadAuthority, authorize: () => Promise<unknown> = async()=>{}, signal?: AbortSignal) {
const deadline=AbortSignal.timeout(45000), bounded=signal?AbortSignal.any([signal,deadline]):deadline
const check=async()=>{bounded.throwIfAborted();await authorize();await assertBackupDownloadAccess(authority);bounded.throwIfAborted()}
await check()
const before=await sourceMetadata(authority)
await check()
let content:Buffer
let origin: "s3" | "local" | undefined
try { content=await readBackupContent({id:authority.backupId} as BackupMeta,{maxBytes:MAX_BYTES,signal:bounded,authorize:check,sourceRead:source=>{origin=source}}) }
catch {await check();throw fail("Файл резервной копии недоступен, превышает 8 МиБ или его чтение прервано","STATE_UNAVAILABLE")}
await check()
const after=await sourceMetadata(authority)
if(fingerprint(before)!==fingerprint(after))throw fail("Источник изменился во время чтения","STATE_CONFLICT")
await check()
if(!origin)throw fail("Источник файла не подтверждён","STATE_UNAVAILABLE")
const filename=String(after.backup.filename).replace(/[^a-zA-Z0-9._-]/g,"_").slice(0,255) || "backup.bin"
return {content,filename,serverIds:[authority.serverId],proof:await sign({metadata:after,origin,content:content.toString("base64")})}
}
export async function createBackupDownloadArtifact(authority: BackupDownloadAuthority, expectedProof: string) {
const authorize = assertVerifiedConfigurationAccess
return executeGuardedApplicationWrite({kind:"backup-download",id:authority.backupId},(await assertBackupDownloadAccess(authority)).serverIds,async()=>{
const source = await readBackupDownloadSource(authority,authorize)
if (source.proof!==expectedProof) throw fail("Резервная копия изменилась после подтверждения","STATE_CONFLICT")
const ciphertext = await encryptSecret(source.content.toString("base64"))
if(Buffer.byteLength(ciphertext)>MAX_CIPHER) throw fail("Зашифрованный пакет превышает предел","EXPORT_LIMIT")
const header: Header = { id: randomUUID(), connection_id: authority.principal.connectionId, filename: source.filename, media_type: "application/octet-stream", expires_at: new Date(Date.now()+900000) }
const access = { version: 1 as const, kind: "backup-download" as const, authority, sourceProof: source.proof, bytes: source.content.length, sha256: hash(source.content), encryptedSha256: hash(ciphertext) }
const scope = scopeSchema.parse({...access,proof:await sign(signed(header,access))})
await authorize()
if ((await readBackupDownloadSource(authority,authorize)).proof!==source.proof) throw fail("Конфигурация изменилась во время формирования пакета","STATE_CONFLICT")
await transaction(async client=>{
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE",[header.connection_id])
await assertBackupDownloadAccess(authority,client)
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('mcp-log-artifact-storage',0))")
await client.query("DELETE FROM mcp_artifacts WHERE expires_at<=now()")
const usage = (await client.query(`SELECT count(*) FILTER(WHERE connection_id=$1)::text AS files,COALESCE(sum(octet_length(ciphertext)) FILTER(WHERE connection_id=$1),0)::text AS own_bytes,COALESCE(sum(octet_length(ciphertext)),0)::text AS total_bytes FROM mcp_artifacts`,[header.connection_id])).rows[0]
assertLogArtifactBudget(BigInt(usage.files),BigInt(usage.own_bytes),BigInt(usage.total_bytes),Buffer.byteLength(ciphertext))
await client.query("INSERT INTO mcp_artifacts(id,connection_id,filename,media_type,ciphertext,access_scope,expires_at) VALUES($1,$2,$3,$4,$5,$6::jsonb,$7)",[header.id,header.connection_id,header.filename,header.media_type,ciphertext,JSON.stringify(scope),header.expires_at])
})
await authorize();await assertBackupDownloadAccess(authority)
return {artifactId:header.id,downloadPath:`/api/mcp/artifacts/${header.id}`,expiresInSeconds:900,bytes:access.bytes,sha256:access.sha256,humanDownloadOnly:true,backupContentReturned:false,configurationApplied:false}
})
}
async function loadMeta(id: string): Promise<Meta> {
const row = (await pool.query<Meta>("/* MCP backup artifact metadata */ SELECT id,connection_id,filename,media_type,expires_at,CASE WHEN octet_length(access_scope::text)<=32768 THEN access_scope ELSE NULL END AS access_scope,octet_length(ciphertext)::int AS encrypted_bytes FROM mcp_artifacts WHERE id=$1 AND expires_at>now() AND octet_length(filename)<=1024 AND octet_length(media_type)<=200",[id])).rows[0]
if (!row) throw new McpError("NOT_FOUND","Пакет отсутствует или срок истёк",404)
const scope = scopeSchema.safeParse(row.access_scope)
if (!scope.success || !(row.expires_at instanceof Date) || !Number.isFinite(row.expires_at.getTime()) || !Number.isSafeInteger(row.encrypted_bytes) || row.encrypted_bytes>MAX_CIPHER) throw fail("Метаданные пакета не подтверждены")
const {proof,...access} = scope.data, expected = await sign(signed(row,access))
if (!timingSafeEqual(Buffer.from(proof,"hex"),Buffer.from(expected,"hex")) || row.connection_id!==access.authority.principal.connectionId) throw fail("Подпись пакета не совпала")
return {...row,access_scope:scope.data}
}
export async function verifyBackupDownloadArtifact(id: string, authority: BackupDownloadAuthority, sourceProof: string) {
const meta=await loadMeta(id)
await assertBackupDownloadAccess(authority)
if(fingerprint(meta.access_scope.authority)!==fingerprint(authority) || meta.access_scope.sourceProof!==sourceProof)return false
const payload=(await pool.query<{ciphertext:string}>("SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND access_scope->>'proof'=$2 AND expires_at>now() AND octet_length(ciphertext)<=16777216",[id,meta.access_scope.proof])).rows[0]
await assertBackupDownloadAccess(authority)
return !!payload && hash(payload.ciphertext)===meta.access_scope.encryptedSha256 && (await readBackupDownloadSource(authority)).proof===sourceProof
}
/** Receipt listing validates sealed metadata and access without reading source or artifact payload. */
export async function humanBackupDownloadMetadata(id: string, actor: AuthUser, checkClaims = assertPortalClaimsCurrent) {
const meta = await loadMeta(id), scope = meta.access_scope
await checkClaims(actor)
if (actor.id !== scope.authority.ownerId || !hasPermission(actor.permissions, "mm:backups:write")) throw new McpError("PERMISSION_DENIED", "Файл доступен только владельцу", 403)
await assertBackupDownloadAccess(scope.authority)
return scope
}
export async function readHumanBackupDownloadArtifact(id: string, actor: AuthUser, checkClaims = assertPortalClaimsCurrent, signal?:AbortSignal) {
const deadline=AbortSignal.timeout(45000),bounded=signal?AbortSignal.any([signal,deadline]):deadline
bounded.throwIfAborted()
const meta=await loadMeta(id), scope=meta.access_scope
const authorize=async()=>{
bounded.throwIfAborted()
await checkClaims(actor)
if(actor.id!==scope.authority.ownerId || !hasPermission(actor.permissions,"mm:backups:write")) throw new McpError("PERMISSION_DENIED","Резервная копия доступна только владельцу с правом управления backup",403)
await assertBackupDownloadAccess(scope.authority)
if(meta.expires_at.getTime()<=Date.now()) throw fail("Срок пакета истёк")
}
await authorize()
if((await readBackupDownloadSource(scope.authority,authorize,bounded)).proof!==scope.sourceProof) throw fail("Резервная копия изменилась; подготовьте новый пакет","STATE_CONFLICT")
const payload=(await pool.query<{ciphertext:string}>("/* MCP backup artifact payload */ SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND access_scope->>'proof'=$2 AND expires_at>now() AND octet_length(ciphertext)<=16777216",[id,scope.proof])).rows[0]
await authorize()
if(!payload || hash(payload.ciphertext)!==scope.encryptedSha256) throw fail("Зашифрованный пакет изменился")
let content: Buffer
try{content=Buffer.from(await decryptSecret(payload.ciphertext),"base64")}catch{throw fail("Пакет не расшифрован")}
await authorize()
if(Buffer.byteLength(content)!==scope.bytes || hash(content)!==scope.sha256) throw fail("Размер или SHA-256 пакета не совпал")
if((await readBackupDownloadSource(scope.authority,authorize,bounded)).proof!==scope.sourceProof) throw fail("Конфигурация изменилась во время выдачи пакета","STATE_CONFLICT")
await authorize()
return {filename:meta.filename,mediaType:meta.media_type,content}
}
export function backupDownloadAuthority(principal: McpPrincipal, ownerId: string, policy: unknown, generation: string, backupId:string, serverId:number) {
return backupDownloadAuthoritySchema.parse({principal,ownerId,policyProof:fingerprint(policy),generation,backupId,serverId})
}
@@ -0,0 +1,10 @@
import assert from "node:assert/strict"
import { backupStorageSecretPurpose as purpose } from "./backup-storage-actions.js"
const destination={s3Endpoint:"https://s3.example.test",s3Region:"us-east-1",s3Bucket:"backups"}
assert.equal(purpose(destination),"backup-s3:https://s3.example.test|us-east-1|backups")
assert.equal(purpose({...destination,s3Endpoint:""}),"backup-s3:aws-default|us-east-1|backups")
assert.equal(purpose({...destination,s3Endpoint:" https://s3.example.test ",s3Bucket:" backups "}),purpose(destination))
for(const patch of [{s3Endpoint:"https://user:[email protected]"},{s3Endpoint:"https://s3.example.test?secret=x"},{s3Endpoint:"https://s3.example.test#x"},{s3Endpoint:"ftp://s3.example.test"},{s3Region:""},{s3Bucket:" "},{s3Region:"a|b"},{s3Bucket:"a|b"}])assert.throws(()=>purpose({...destination,...patch}))
assert.notEqual(purpose({...destination,s3Endpoint:"https://other.example.test"}),purpose(destination))
assert.notEqual(purpose({...destination,s3Bucket:"other"}),purpose(destination))
console.log("Backup S3 secret destination: exact endpoint/region/bucket binding, AWS-default target, delimiter collision, URL credentials and incomplete target guards passed")
@@ -0,0 +1,82 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { readBackupStorageMutationState, updateBackupStorageSettings } from "../../services/backup-service.js"
import { decryptSecret, fingerprint, privateSecretFingerprint, McpError } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import type { add } from "./actions.js"
import type { ChangeEvidence } from "./action-types.js"
const endpoint=z.string().trim().max(350).refine(value=>{
if(!value)return true
try {const url=new URL(value);return ["http:","https:"].includes(url.protocol)&&!url.username&&!url.password&&!url.search&&!url.hash}catch{return false}
},"Endpoint должен быть HTTP(S) URL без credentials/query/hash")
const destinationPart=z.string().trim().max(64).refine(value=>!/[|\r\n]/.test(value),"Недопустимый разделитель назначения S3")
const fields=z.object({provider:z.enum(["local","s3"]).optional(),s3Endpoint:endpoint.optional(),s3Region:destinationPart.optional(),s3Bucket:destinationPart.optional(),s3Prefix:z.string().max(1024).optional(),s3AccessKeyId:z.string().max(256).optional(),s3ForcePathStyle:z.boolean().optional(),keepLocalCopy:z.boolean().optional()}).strict()
const schema=z.object({settings:fields.default({}),secretRef:z.string().uuid().optional()}).strict().refine(value=>Object.keys(value.settings).length>0||Boolean(value.secretRef),"Нужно изменение настроек или ссылка на секрет")
type State=Awaited<ReturnType<typeof readBackupStorageMutationState>>
export function backupStorageSecretPurpose(configuration:Pick<State["configuration"],"s3Endpoint"|"s3Region"|"s3Bucket">) {
const parsed=endpoint.safeParse(configuration.s3Endpoint)
const region=destinationPart.safeParse(configuration.s3Region),bucket=destinationPart.safeParse(configuration.s3Bucket)
if(!parsed.success||!region.success||!bucket.success||!region.data||!bucket.data)throw new McpError("STATE_CONFLICT","Не подтверждены endpoint/region/bucket назначения S3",409)
const purpose=`backup-s3:${parsed.data||"aws-default"}|${region.data}|${bucket.data}`
if(purpose.length>512)throw new McpError("INVALID_INPUT","Назначение секрета S3 превышает предел",400)
return purpose
}
async function selectedServers() {
const ids=(await pool.query<{id:number}>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row=>Number(row.id))
if(ids.length>1000)throw new McpError("STATE_UNAVAILABLE","Каталог серверов превышает предел проверки",503)
return ids
}
async function resolveReference(input:Record<string,unknown>,state:State) {
if(!input.secretRef)return undefined
const next={...state.configuration,...fields.parse(input.settings)}
const {rows:[secret]}=await pool.query("SELECT purpose,server_id,ciphertext,created_by FROM mcp_secrets WHERE id=$1 AND created_by=$2",[input.secretRef,input.ownerId])
if(!secret||secret.server_id!==null||secret.purpose!==backupStorageSecretPurpose(next))throw new McpError("SECRET_UNAVAILABLE","Секрет не выдан владельцу для выбранного хранилища S3",403)
const parsed=z.string().min(1).max(4096).safeParse(await decryptSecret(secret.ciphertext))
if(!parsed.success)throw new McpError("SECRET_UNAVAILABLE","Формат выданного секрета S3 не поддерживается",403)
const value=parsed.data
return {value,referenceFingerprint:fingerprint(secret)}
}
async function current(input:Record<string,unknown>) {
const serverIds=await selectedServers(),state=await readBackupStorageMutationState()
if(!fields.required().safeParse(state.configuration).success)throw new McpError("STATE_UNAVAILABLE","Текущая конфигурация S3 не подтверждена; endpoint не должен содержать credentials",503)
const next={...state.configuration,...fields.parse(input.settings)}
if(state.busy)throw new McpError("STATE_CONFLICT","Хранилище занято созданием или синхронизацией архивов",409)
if(state.hasRemoteEntries&&(next.provider!=="s3"||(["s3Endpoint","s3Region","s3Bucket","s3ForcePathStyle"] as const).some(key=>next[key]!==state.configuration[key])))throw new McpError("STATE_CONFLICT","Существующие S3-архивы требуют сохранения исходного подключения",409)
// An empty bucket cannot construct an S3 client. Clearing dormant settings is
// allowed, but restoring a usable destination requires its own reference.
if(state.secretConfigured&&next.s3Bucket.trim()&&!input.secretRef&&(["s3Endpoint","s3Region","s3Bucket"] as const).some(key=>next[key]!==state.configuration[key]))throw new McpError("SECRET_UNAVAILABLE","Перенос сохранённого секрета на новое назначение S3 требует выданной этому назначению ссылки",403)
const reference=await resolveReference(input,state)
return {...state,serverIds,secretRefFingerprint:reference?.referenceFingerprint??null}
}
export function registerBackupStorageActions(register:typeof add) {
registerMcpService("backups.storage.update",{current,execute:async input=>{
const before=await current(input)
if(fingerprint(before)!==input.beforeFingerprint)throw new McpError("STATE_CONFLICT","Настройки, секрет, архивы или серверы изменились после подтверждения",409)
const reference=await resolveReference(input,before)
if((reference?.referenceFingerprint??null)!==input.secretRefFingerprint)throw new McpError("STATE_CONFLICT","Ссылка на секрет изменилась перед записью",409)
const patch={...fields.parse(input.settings),...(reference?{s3SecretAccessKey:reference.value}:{})}
await updateBackupStorageSettings(patch)
return {configurationSaved:true,configurationFingerprint:fingerprint({...before.configuration,...fields.parse(input.settings)}),secretFingerprint:reference?await privateSecretFingerprint(reference.value,"backup-storage"):before.secretFingerprint,storageTestVerified:false}
},verify:async(_input,result)=>{
const receipt=result as {configurationSaved?:boolean;configurationFingerprint?:string;secretFingerprint?:string}|null
if(receipt?.configurationSaved!==true)return false
const saved=await readBackupStorageMutationState()
return saved.secretFingerprint===receipt.secretFingerprint&&fingerprint(saved.configuration)===receipt.configurationFingerprint
}})
register("backups.storage.update","mm_backup_storage_update_plan","Изменение конфигурации backup-хранилища с отдельной ссылкой на секрет","backups","mm:backups:write","write","global",schema,{
prepare:async(input,context)=>{
const ids=await selectedServers()
if(ids.some(id=>!context.connection.policy.scope.serverIds.includes(id)))throw new McpError("SERVER_DENIED","Глобальное хранилище затрагивает серверы вне разрешённой области",403)
const request={...input,ownerId:context.connection.owner_id},before=await current(request)
if(before.serverIds.join(",")!==ids.join(","))throw new McpError("STATE_CONFLICT","Каталог серверов изменился во время подготовки",409)
const objects:ChangeEvidence["objects"]=[{kind:"backup-storage",id:"1"},...ids.map(serverId=>({kind:"server",id:String(serverId),serverId}))]
const {rows:protections}=await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) LIMIT 1001",[ids.map(String)])
if(protections.length>1000)throw new McpError("STATE_UNAVAILABLE","Превышен предел проверки защит",503)
for(const {object_ref:ref} of protections)objects.push({kind:ref.kind,id:ref.id,serverId:ref.serverId})
return {target:{scope:"global"},objects,snapshots:[],before,requiresHuman:true,mutationCost:1,
steps:[{kind:"service",service:"backups.storage.update",input:{...request,beforeFingerprint:fingerprint(before),secretRefFingerprint:before.secretRefFingerprint}}],
warnings:["Глобальная конфигурация backup-хранилища изменяется для всех серверов приложения.","Смена credentials/prefix может повлиять на последующие загрузки и доступ к архивам. Проверка S3 выполняется отдельным подтверждённым действием.","Секрет передаётся только через выданную владельцу ссылку с purpose backup-s3:<endpoint или aws-default>|<region>|<bucket>.","Занятое созданием/синхронизацией хранилище отклонит изменение; автоматического повтора нет."]}
},
})
}
@@ -0,0 +1,200 @@
import { MIGRATION_MANIFEST } from "../../db/migration-manifest.js"
/** Owned SQL-only acceptance of the genuine production runner's private service
* context. No RouterOS request, bandwidth admission or production registration. */
import assert from "node:assert/strict"
import { randomUUID, createHash } from "node:crypto"
import { setTimeout as delay } from "node:timers/promises"
import { isDeepStrictEqual } from "node:util"
import type { McpPrincipal } from "./store.js"
import type { VerifiedMcpServiceExecution } from "../../services/config-mutation-policy.js"
import { assertOwnedBandwidthSql } from "../../services/bandwidth-admission-process.sql-helper.js"
export async function acceptBandwidthServiceContextSql(serverIds: readonly number[]) {
assertOwnedBandwidthSql()
assert.equal(serverIds.length, 3); assert.equal(new Set(serverIds).size, 3)
assert(serverIds.every(id => Number.isSafeInteger(id) && id > 0))
const { pool } = await import("../../db/index.js")
const { z } = await import("zod")
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
const { add } = await import("./actions.js")
const { registerMcpService } = await import("./service-registry.js")
const { createMcpConnection, authenticateMcpToken } = await import("./store.js")
const { invokeMcpTool, approvePlan, applyPlan } = await import("./runtime.js")
const { captureVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecution } = await import("../../services/config-mutation-policy.js")
const { readCurrentMcpServiceExecution } = await import("./service-execution-origin.js")
const { readMcpTemporaryGrantOrigin } = await import("./temporary-grant-origin.js")
const ids = [...serverIds].sort((a, b) => a - b)
assert.equal((await pool.query("SELECT id FROM public.servers WHERE id=ANY($1::bigint[])", [ids])).rows.length, 3)
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM public.schema_migrations")).rows[0].n), MIGRATION_MANIFEST.length)
const originalEnabled = (await pool.query("SELECT enabled FROM public.mcp_settings WHERE id=1")).rows[0]?.enabled
assert.equal(typeof originalEnabled, "boolean")
const suffix = randomUUID().replaceAll("-", ""), operationName = "fixture.bandwidth.context." + suffix
const serviceName = "fixture.bandwidth.service-context." + suffix, toolName = "mm_fixture_bw_context_" + suffix
const secret = "SYNTHETIC_PRIVATE_SERVICE_PASSWORD_" + randomUUID()
const before = { fixture: "owned-sealed-service-state" }, connections: string[] = [], tokens: VerifiedMcpServiceExecution[] = []
const checks: string[] = [], outcomes: boolean[] = []
type Expected = { principal: McpPrincipal; ownerId: string; connectionId: string; approved: boolean; evidenceExpiry: number;grantExpiresAt:number|null;grantRowId:string|null }
const expectedPlans = new Map<string, Expected>(), seenSteps = new Map<string, number[]>()
let preparedExpiry = 0, verified = 0, currentPlanChecks = 0, rejectedPlanChanges = 0, rejectedGrantChanges=0, pendingReplayRefusals=0
const privateInput = (stepIndex: number) => ({ fixtureStep: stepIndex, password: secret, serverIds: ids })
const safe = (value: unknown) => { assert(!JSON.stringify(value).includes(secret), "Private service material escaped") }
registerMcpService(serviceName, {
current: async () => before,
verify: async (_input, result) => {
assert.deepEqual(result, { fixtureServiceReceipt: true }); safe(result); verified++; return true
},
execute: async (input, context) => {
assert(context && context.phase === "forward")
const token = await captureVerifiedMcpServiceExecution(), inspected = await inspectVerifiedMcpServiceExecution(token)
const originalScope={deadlineAt:Date.now()+10000}
assert(isDeepStrictEqual(await readCurrentMcpServiceExecution(token,originalScope),inspected));currentPlanChecks++
const expected = expectedPlans.get(inspected.planId); assert(expected)
const currentGrant=await readMcpTemporaryGrantOrigin(pool,inspected.connectionId,inspected.principal.credentialId)
assert.equal(inspected.originalTemporaryGrantProof,currentGrant.proof);assert.equal(inspected.originalTemporaryGrantExpiresAt,expected.grantExpiresAt)
assert.equal(context.operationId, inspected.operationId); assert.equal(context.stepIndex, inspected.stepIndex)
assert.equal(inspected.stepIndex, input.fixtureStep)
assert(isDeepStrictEqual(input, privateInput(inspected.stepIndex)), "Decrypted runner step input mismatch")
assert(isDeepStrictEqual(inspected.serviceInput, privateInput(inspected.stepIndex)), "Private inspected service input mismatch")
assert(inspected.serviceInput.password === secret, "Private inspected credential mismatch")
assert.equal(inspected.operationName, operationName); assert.equal(inspected.serviceName, serviceName); assert.equal(inspected.phase, "forward")
assert.equal(inspected.operationVersion, 2); assert.deepEqual(inspected.principal, expected.principal)
assert.equal(inspected.connectionId, expected.connectionId); assert.equal(inspected.ownerId, expected.ownerId)
assert.equal(inspected.approved, expected.approved); assert.equal(inspected.serverWide, true)
assert.deepEqual([...inspected.serverIds].sort((a, b) => a - b), ids)
assert.deepEqual(inspected.objects, ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })))
const row = (await pool.query(`SELECT o.state AS operation_state,p.state AS plan_state,s.state AS step_state,
o.plan_id,o.connection_id,p.operation_id,p.operation_version,p.policy_version,p.generation,p.expires_at,
p.approved_by,p.evidence,p.payload,c.owner_id,c.owner_valid_until,c.policy,s.intended_state
FROM public.mcp_operations o JOIN public.mcp_plans p ON p.id=o.plan_id
JOIN public.mcp_connections c ON c.id=o.connection_id
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=$2 WHERE o.id=$1`, [context.operationId, context.stepIndex])).rows[0]
assert(row); assert.equal(row.operation_state, "running"); assert.equal(row.plan_state, "executing"); assert.equal(row.step_state, "intent")
assert.equal(row.plan_id, inspected.planId); assert.equal(row.connection_id, inspected.connectionId); assert.equal(row.owner_id, inspected.ownerId)
assert.equal(row.operation_id, inspected.operationName); assert.equal(row.operation_version, inspected.operationVersion)
assert.equal(row.policy_version, inspected.policyVersion); assert.equal(row.generation, inspected.generation)
assert.equal(inspected.sealedEvidenceProof,createHash("sha256").update(row.evidence.sealed).digest("hex"))
assert.equal(inspected.sealedPayloadProof,createHash("sha256").update(row.payload.sealed).digest("hex"))
assert.equal(!!row.approved_by, expected.approved)
safe(row.intended_state); assert.equal(row.intended_state.input.password, "[redacted]")
if(inspected.stepIndex===0){
const replacementId=randomUUID(),grantPayload={credentialId:inspected.principal.credentialId,id:randomUUID(),startsAt:new Date(Date.now()-1000).toISOString(),expiresAt:new Date(Date.now()+100000).toISOString(),scope:{serverIds:ids,operationIds:[operationName],allowGlobal:false}}
if(expected.grantRowId){
const originalGrant=(await pool.query("SELECT payload FROM public.mcp_grants WHERE id=$1",[expected.grantRowId])).rows[0].payload
await pool.query("UPDATE public.mcp_grants SET payload=jsonb_set(payload,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1",[expected.grantRowId,new Date(expected.grantExpiresAt!+1000).toISOString()])
try{await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedGrantChanges++}
finally{await pool.query("UPDATE public.mcp_grants SET payload=$2::jsonb WHERE id=$1",[expected.grantRowId,JSON.stringify(originalGrant)])}
await pool.query("UPDATE public.mcp_grants SET revoked_at=now() WHERE id=$1",[expected.grantRowId])
try{
await pool.query("INSERT INTO public.mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[replacementId,inspected.connectionId,JSON.stringify(originalGrant)])
await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedGrantChanges++
}finally{await pool.query("DELETE FROM public.mcp_grants WHERE id=$1",[replacementId]);await pool.query("UPDATE public.mcp_grants SET revoked_at=NULL WHERE id=$1",[expected.grantRowId])}
}else{
await pool.query("INSERT INTO public.mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[replacementId,inspected.connectionId,JSON.stringify(grantPayload)])
try{await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedGrantChanges++}
finally{await pool.query("DELETE FROM public.mcp_grants WHERE id=$1",[replacementId])}
}
await readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000})
// Only this fixture's active step/plan is changed and restored. The
// runtime is awaiting this handler; no native operation is involved.
const changes=[
{change:"UPDATE public.mcp_operation_steps SET intended_state='{}'::jsonb WHERE operation_id=$1 AND step_index=0",restore:"UPDATE public.mcp_operation_steps SET intended_state=$2::jsonb WHERE operation_id=$1 AND step_index=0",id:inspected.operationId,value:JSON.stringify(row.intended_state)},
{change:"UPDATE public.mcp_plans SET evidence=jsonb_set(evidence,'{sealed}',to_jsonb((evidence->>'sealed')||'!')) WHERE id=$1",restore:"UPDATE public.mcp_plans SET evidence=$2::jsonb WHERE id=$1",id:inspected.planId,value:JSON.stringify(row.evidence)},
{change:"UPDATE public.mcp_plans SET payload=jsonb_set(payload,'{sealed}',to_jsonb((payload->>'sealed')||'!')) WHERE id=$1",restore:"UPDATE public.mcp_plans SET payload=$2::jsonb WHERE id=$1",id:inspected.planId,value:JSON.stringify(row.payload)},
{change:"UPDATE public.mcp_plans SET operation_version=operation_version+1 WHERE id=$1",restore:"UPDATE public.mcp_plans SET operation_version=$2 WHERE id=$1",id:inspected.planId,value:row.operation_version},
]
for(const change of changes){
await pool.query(change.change,[change.id])
try{await assert.rejects(readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000}));rejectedPlanChanges++}
finally{await pool.query(change.restore,[change.id,change.value])}
await readCurrentMcpServiceExecution(token,{deadlineAt:Date.now()+10000});currentPlanChecks++
}
}
assert.equal(inspected.originalExpiresAt, Math.min(row.expires_at.getTime(), expected.evidenceExpiry,
Date.parse(expected.principal.credentialExpiresAt), Date.parse(row.policy.expiresAt), row.owner_valid_until.getTime(),expected.grantExpiresAt??Infinity))
assert(inspected.originalExpiresAt > Date.now()); assert(Object.isFrozen(inspected)); assert(Object.isFrozen(inspected.serviceInput))
await assert.rejects(inspectVerifiedMcpServiceExecution({ ...token }))
if (tokens.length) await assert.rejects(inspectVerifiedMcpServiceExecution(tokens[tokens.length - 1]))
tokens.push(token)
const seen = seenSteps.get(inspected.planId) ?? []; seen.push(inspected.stepIndex); seenSteps.set(inspected.planId, seen)
outcomes.push(inspected.approved)
return { fixtureServiceReceipt: true }
},
})
add(operationName, toolName, "Owned bandwidth runner context fixture", "servers", "mm:servers:write", "write", "servers", z.object({}).strict(), {
version: 2, target: () => ({ scope: "servers", serverIds: ids }), prepare: async () => {
preparedExpiry = Date.now() + 120000
return { target: { scope: "servers", serverIds: ids }, objects: ids.map(serverId => ({ kind: "server" as const, id: String(serverId), serverId })), snapshots: [],
steps: [0, 1].map(stepIndex => ({ kind: "service" as const, service: serviceName, input: privateInput(stepIndex) })),
before, warnings: [], validUntil: new Date(preparedExpiry).toISOString() }
},
})
try {
await pool.query("UPDATE public.mcp_settings SET enabled=true WHERE id=1")
await assert.rejects(captureVerifiedMcpServiceExecution())
for (const approved of [true, false]) {
const actor = { id: randomUUID(), email: "[email protected]", name: "Synthetic owned runner authority", apps: ["mm"],
permissions: ["mm:servers:write", "mm:servers:read", "mm:mcp:approve"], issuedAt: Date.now() - 1000, expiresAt: Date.now() + 300000 }
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned runner private context", mode: approved ? "approval" : "autonomous",
expiresAt: new Date(actor.expiresAt).toISOString(), permissions: ["mm:servers:write", "mm:servers:read"],
scope: { serverIds: ids, operationIds: [operationName], allowGlobal: false },
...(approved ? {} : { dangerousPolicy: "explicit-allow", autonomousDangerousOperationIds: [operationName] }),
limits: { callsPerMinute: 120, mutationsPerHour: 1000 } }), actor)
connections.push(access.connection.id)
const principal = await authenticateMcpToken(access.token)
let grantRowId:string|null=null,grantExpiresAt:number|null=null
if(approved){
grantRowId=randomUUID();grantExpiresAt=Date.now()+90000
await pool.query("INSERT INTO public.mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[grantRowId,access.connection.id,JSON.stringify({credentialId:principal.credentialId,id:randomUUID(),startsAt:new Date(Date.now()-1000).toISOString(),expiresAt:new Date(grantExpiresAt).toISOString(),scope:{serverIds:ids,operationIds:[operationName],allowGlobal:false}})])
}
const prepared = await invokeMcpTool(principal, toolName, {}) as { planId: string; state: string }
safe(prepared); assert.equal(prepared.state, approved ? "awaiting-approval" : "draft")
expectedPlans.set(prepared.planId, { principal, ownerId: actor.id, connectionId: access.connection.id, approved, evidenceExpiry: preparedExpiry,grantExpiresAt,grantRowId })
if (approved) await approvePlan(prepared.planId, actor, true)
const operation = await applyPlan(principal, prepared.planId, randomUUID())
let terminal: { state: string; result: unknown; error_code: unknown } | undefined
for (const until = Date.now() + 30000; Date.now() < until;) {
const row = (await pool.query("SELECT state,result,error_code FROM public.mcp_operations WHERE id=$1", [operation.id])).rows[0]
assert(row)
if (!["pending", "running"].includes(row.state)) { terminal = row; break }
await delay(20)
}
assert(terminal, "Owned runner did not reach terminal state"); assert.equal(terminal.state, "applied", "Owned runner did not apply SQL-only fixture steps")
safe(terminal); assert.deepEqual(seenSteps.get(prepared.planId), [0, 1])
const steps = (await pool.query("SELECT state,intended_state,result FROM public.mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index", [operation.id])).rows
assert.equal(steps.length, 2); assert(steps.every(row => row.state === "verified")); safe(steps)
assert.equal((await pool.query("SELECT approved_by FROM public.mcp_plans WHERE id=$1", [prepared.planId])).rows[0].approved_by !== null, approved)
for (const token of tokens) await assert.rejects(inspectVerifiedMcpServiceExecution(token))
// Simulate an owned pending row surviving without its admission closure.
// Re-apply may return it, but must never manufacture a new grant pin or
// schedule another service continuation from the current access alone.
const previousTokens=tokens.length
await pool.query("UPDATE public.mcp_operations SET state='pending' WHERE id=$1",[operation.id])
try{
const replay=await applyPlan(principal,prepared.planId,randomUUID())
assert.equal(replay.id,operation.id);await delay(150)
assert.equal((await pool.query("SELECT state FROM public.mcp_operations WHERE id=$1",[operation.id])).rows[0].state,"pending")
assert.equal(tokens.length,previousTokens);pendingReplayRefusals++
}finally{await pool.query("UPDATE public.mcp_operations SET state='applied' WHERE id=$1",[operation.id])}
checks.push(approved ? "human-approved genuine production runner: two exact sealed service steps, SQL running/executing/intent provenance, private decrypted input and redacted public intent; tokens expire on step exit"
: "explicit autonomous dangerous-operation grant: two genuine service steps with approved=false, same principal/plan/generation/expiry provenance, verification and no human approval fabrication")
}
assert.equal(verified, 4); assert.deepEqual(outcomes, [true, true, false, false]);assert.equal(currentPlanChecks,12);assert.equal(rejectedPlanChanges,8);assert.equal(rejectedGrantChanges,3)
assert.equal(pendingReplayRefusals,2)
return { checks, flows: 2, verifiedServiceSteps: verified, productionRunnerTokens: tokens.length, currentPlanChecks, rejectedPlanChanges, rejectedGrantChanges,pendingReplayRefusals, nativeRequests: 0, operatorAuthoritySynthetic: true, productionActivation: false }
} finally {
try { await pool.query("UPDATE public.mcp_settings SET enabled=$1 WHERE id=1", [originalEnabled]) }
finally {
try {
if (connections.length) {
await pool.query("DELETE FROM public.mcp_operation_checkpoints WHERE operation_id IN (SELECT id FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[]))", [connections])
await pool.query("DELETE FROM public.mcp_operation_steps WHERE operation_id IN (SELECT id FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[]))", [connections])
await pool.query("DELETE FROM public.mcp_operations WHERE connection_id=ANY($1::uuid[])", [connections])
await pool.query("DELETE FROM public.mcp_plans WHERE connection_id=ANY($1::uuid[])", [connections])
await pool.query("DELETE FROM public.mcp_requests WHERE connection_id=ANY($1::uuid[])", [connections])
await pool.query("DELETE FROM public.mcp_connections WHERE id=ANY($1::uuid[])", [connections])
}
}
finally { tokens.splice(0); expectedPlans.clear() }
}
}
}
@@ -0,0 +1,101 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { bgpCommunityPolicySchema, compileBgpCommunityPolicy, readCompiledBgpCommunityPolicy } from "../../services/bgp-community-policy-model.js"
import { readOwnedRoutingPolicy, mutateOwnedRoutingPolicy } from "../../services/owned-routing-policy.js"
import { nativeBgpRows } from "../../services/bgp-export-model.js"
import { stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js"
import { executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
import { routerClient } from "./resources.js"
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import { routingFilterBodiesMatch } from "../../services/routing-filter-body.js"
import type { ChangeEvidence } from "./action-types.js"
import type { add } from "./actions.js"
const schema = bgpCommunityPolicySchema.safeExtend({ serverId: z.number().int().positive().safe(), action: z.enum(["replace", "delete"]).default("replace") }).superRefine((value, context) => {
if (value.action === "delete" && (value.rules.length || value.defaultAction !== "reject")) context.addIssue({ code: "custom", message: "Удаление принимает пустые rules и defaultAction=reject" })
})
const service = "routing.bgp.community.policy"
const paths = { bgp_connection: "/routing/bgp/connection", bgp_template: "/routing/bgp/template", bgp_instance: "/routing/bgp/instance", ospf_instance: "/routing/ospf/instance", rip_instance: "/routing/rip/instance", isis_instance: "/routing/isis/instance", routing_filter_select: "/routing/filter/select-rule", routing_table: "/routing/table", vrf: "/ip/vrf" }
const proof = (sid: number, policy: string, kind: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `bgp-community-policy:${sid}:${policy}:${kind}`)
async function state(input: Record<string, unknown>) {
try {
const serverId = Number(input.serverId), compiled = compileBgpCommunityPolicy({ policyId: input.policyId, rules: input.rules, defaultAction: input.defaultAction })
const { server, client } = await routerClient(serverId), serverProof = await proof(serverId, compiled.policy.policyId, "server", server)
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials policy изменились; новый источник не опрашивается", 409)
const native = await readOwnedRoutingPolicy(client, compiled.chain, compiled.comment)
const snapshots = await Promise.all(Object.entries(paths).map(async ([kind, path]) => {
const rows = nativeBgpRows(await client.get(path)); if (rows.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог policy dependencies превышает 1000 строк", 409)
return { kind, rows }
}))
const references: ChangeEvidence["objects"] = []
for (const snapshot of snapshots) for (const row of snapshot.rows) {
if (Object.entries(row).some(([key, value]) => (key.includes("filter") || key === "do-where" || snapshot.kind === "routing_filter_select" && key === "chain") && (typeof value === "string" ? value.split(",").includes(compiled.chain) : Array.isArray(value) && value.includes(compiled.chain)))) references.push({ kind: snapshot.kind, id: row[".id"], serverId })
}
const queryProof = []
// Native positive queries cover omitted/effective binding properties, including
// every template, rather than treating a missing GET key as a known false.
for (const [kind, path] of [["bgp_connection", "/routing/bgp/connection"], ["bgp_template", "/routing/bgp/template"]] as const) {
const rows = nativeBgpRows(await client.post(path + "/print", { ".proplist": ".id,input.filter,output.filter-chain", ".query": ["input.filter=" + compiled.chain, "output.filter-chain=" + compiled.chain, "#|"] }))
const catalog = snapshots.find(row => row.kind === kind)!.rows
if (rows.length > 1000 || rows.some(row => !catalog.some(source => source[".id"] === row[".id"]) || row["input.filter"] !== compiled.chain && row["output.filter-chain"] !== compiled.chain)) throw new McpError("STATE_UNAVAILABLE", "Не подтверждён native query зависимостей policy", 409)
queryProof.push({ kind, rows: rows.map(stableBgpPolicyRow) })
references.push(...rows.map(row => ({ kind, id: row[".id"], serverId })))
}
for (const row of native.rows.filter(row => row[".id"] !== native.current?.[".id"])) {
if (typeof row.rule !== "string") throw new McpError("STATE_UNAVAILABLE", "Не подтверждён код других цепочек при проверке references", 409)
const jumps = row.rule.match(/\bjump\b/gi) ?? [], targets = [...row.rule.matchAll(/\bjump\s+(?:"([^"\\]*)"|([A-Za-z0-9_.:@/-]+))\s*(?:[;}]|$)/gi)]
if (jumps.length !== targets.length && input.action === "delete") throw new McpError("STATE_UNAVAILABLE", "Opaque jump references не подтверждены; удаление запрещено", 409)
if (targets.some(match => (match[1] ?? match[2]) === compiled.chain)) references.push({ kind: "routing_filter", id: row[".id"], serverId })
}
const uniqueReferences = [...new Map(references.map(row => [fingerprint(row), row])).values()]
if (input.action === "delete" && uniqueReferences.length) throw new McpError("STATE_CONFLICT", "Policy используется routing client/template/jump; сначала нужен отдельный план отвязки", 409)
const protections = (await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(serverId)])).rows
if (protections.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Policy protections превышают 1000 объектов", 409)
const objects: ChangeEvidence["objects"] = [{ kind: "bgp-community-policy", id: compiled.policy.policyId, serverId }, ...native.rows.map(row => ({ kind: "routing_filter", id: row[".id"], serverId })),
...snapshots.flatMap(snapshot => snapshot.rows.map(row => ({ kind: snapshot.kind, id: row[".id"], serverId }))), ...protections.map(({ object_ref: ref }) => ({ kind: ref.kind, id: ref.id, serverId }))]
const uniqueObjects = [...new Map(objects.map(row => [fingerprint(row), row])).values()]
if (uniqueObjects.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Policy dependency closure превышает 1000 объектов", 409)
const desired = input.action === "delete" ? null : compiled.body
const dependencyProof = await proof(serverId, compiled.policy.policyId, "dependencies", { serverProof, snapshots: snapshots.map(snapshot => ({ kind: snapshot.kind, rows: snapshot.rows.map(stableBgpPolicyRow) })), queryProof, protections })
const previousPolicy = native.current ? readCompiledBgpCommunityPolicy(compiled.policy.policyId, native.current.rule) : null
const before = { serverId, policyId: compiled.policy.policyId, chain: compiled.chain, nativeId: native.current?.[".id"] ?? null, exists: !!native.current, currentDisabled: native.current?.disabled ?? null, previousPolicy, previousPolicyRecognized: !!previousPolicy, previousBodyBytes: native.current ? Buffer.byteLength(String(native.current.rule), "utf8") : 0, references: uniqueReferences, nativeReferenceQueryMatches: queryProof.reduce((sum, query) => sum + query.rows.length, 0),
desiredPolicy: compiled.policy, desiredAction: input.action, compiledBytes: Buffer.byteLength(compiled.body, "utf8"), serverProof, dependencyProof,
nativeProof: await proof(serverId, compiled.policy.policyId, "native", native.proofRows), compiledProof: await proof(serverId, compiled.policy.policyId, "compiled", desired),
nativeDispatch: desired === null ? !!native.current : !native.current || !routingFilterBodiesMatch(native.current.rule, desired) || native.current.disabled !== "false" }
return { server, client, compiled, native, desired, before, objects: uniqueObjects }
} catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены policy или routing dependencies; запись не выполняется", 409) }
}
export function registerBgpCommunityPolicyActions(register: typeof add) {
registerMcpService(service, {
current: async input => (await state(input)).before,
execute: async input => {
const initial = await state(input)
if (fingerprint(initial.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Policy или зависимости изменились после подтверждения", 409)
return executeGuardedApplicationWrite({ kind: "bgp-community-policy", id: initial.compiled.policy.policyId }, [initial.server.id], async () => {
const result = await mutateOwnedRoutingPolicy(initial.client, initial.compiled.chain, initial.compiled.comment, initial.desired, async native => {
const live = await state(input)
if (fingerprint(live.before) !== input.beforeFingerprint || fingerprint(native.proofRows) !== fingerprint(live.native.proofRows)) throw new McpError("STATE_CONFLICT", "Policy изменена перед native dispatch", 409)
})
return { nativeDispatch: result.nativeDispatch, action: result.action, configurationVerified: true, forwardingVerified: false, nativeProof: await proof(initial.server.id, initial.compiled.policy.policyId, "native", result.after.proofRows), dependencyProof: initial.before.dependencyProof }
}, async () => initial.objects)
},
verify: async (input, result) => {
const after = await state(input), receipt = result as { nativeProof: string; dependencyProof: string }
const nativeMatches = after.before.nativeProof === receipt.nativeProof, dependenciesMatch = after.before.dependencyProof === receipt.dependencyProof
const desiredMatches = after.desired === null ? !after.native.current : routingFilterBodiesMatch(after.native.current?.rule, after.desired) && after.native.current?.disabled === "false"
if (!nativeMatches || !dependenciesMatch || !desiredMatches) console.warn("bgp-community-policy.verification.mismatch", { serverId: after.server.id, nativeMatches, dependenciesMatch, desiredMatches })
return nativeMatches && dependenciesMatch && desiredMatches
},
})
register("routing.bgp.community.policy", "mm_bgp_community_policy_plan", "Человеческий план owned export-цепочки: CIDR и стандартные Community, без raw script; привязка BGP отдельным планом", "bgp", ["mm:filters:write", "mm:bgp:write", "mm:network:read"], "write", "servers", schema, { prepare: async input => {
const saved = await state(input)
return { target: { scope: "servers", serverIds: [saved.server.id] }, objects: saved.objects, snapshots: [], before: saved.before, mutationCost: 1, requiresHuman: true, verificationScope: "configuration",
steps: [{ kind: "service", service, input: { ...input, beforeFingerprint: fingerprint(saved.before), expectedServerProof: saved.before.serverProof } }],
warnings: ["Полностью заменяется и включается одна owned цепочка mmapp-bgp-out-policyId. CIDR приводятся к адресу сети, порядок правил сохраняется. Default reject может отозвать все остальные префиксы.",
"Set/append/clear меняют только стандартные Community. Цепочка не привязывается к peer автоматически; все использующие её routing clients затрагиваются её изменением.",
"Удаление запрещено при native BGP/template, других routing client и jump references. Opaque jump при удалении требует отдельного анализа.",
"Никакого автоматического refresh/повтора/rollback. Обмен attributes, выбор маршрута и forwarding требуют отдельной сетевой проверки.",
...(saved.native.current && !saved.before.previousPolicyRecognized ? ["Текущее тело owned policy не распознано как точный результат компилятора. PreviousPolicy неизвестна; смысл native кода не восстановлен. Замена удалит это тело целиком."] : [])] }
} })
}
@@ -0,0 +1,131 @@
import { z } from "zod"
import { readStaticBgpConnection } from "../../services/bgp-connection-read.js"
import { bgpExportConnection, bgpExportSources, bgpRedistributionMode, bgpTemplateReferences, nativeBgpExportValue, nativeBgpRows, type BgpExportMode, type NativeBgpRow } from "../../services/bgp-export-model.js"
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
import { executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
import { readRosSnapshot, routerClient, type ConfigurationRow } from "./resources.js"
import { registerMcpService } from "./service-registry.js"
import type { ChangeEvidence } from "./action-types.js"
import type { add } from "./actions.js"
const modeSchema = z.enum(["disabled", "connected", "static"]).describe("Connected/static перераспределяют соответствующие маршруты таблицы peer. Disabled очищает только redistribution; output.network/default-originate и сам peer не отключаются.")
const schema = z.object({ serverId: z.number().int().positive().safe(), id: z.string().trim().min(1).max(200), mode: modeSchema }).strict()
const service = "routing.bgp.export"
const modes = (values: BgpExportMode[]) => [...new Set(values)].sort() as BgpExportMode[]
const configurationProof = (serverId: number, id: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `bgp-export-connection:${serverId}:${id}`)
function parseState<T>(phase: string, parse: () => T): T {
try { return parse() } catch (error) {
if (error instanceof McpError) throw error
throw new McpError("STATE_UNAVAILABLE", "Не подтверждён этап BGP export: " + phase, 409)
}
}
async function readState(input: Record<string, unknown>) {
const serverId = Number(input.serverId), id = String(input.id), mode = modeSchema.parse(input.mode)
const { server, client } = await routerClient(serverId)
const serverProof = await privateSecretFingerprint(fingerprint(server), `bgp-export-server:${serverId}`)
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials сервера BGP изменились; новое подключение не опрашивается", 409)
const { connection: hydrated } = await readStaticBgpConnection(client, id)
const localResponse = await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": "output.redistribute" })
const localMode = parseState("локальное output.redistribute", () => bgpRedistributionMode(localResponse))
const templates = nativeBgpRows(await client.get<unknown>("/routing/bgp/template"))
const usedTemplates = new Map<string, { row: NativeBgpRow; mode: BgpExportMode; references: string[]; localMode: BgpExportMode | null }>()
const resolveTemplate = async (reference: string, stack: string[] = []): Promise<BgpExportMode> => {
const found = templates.filter(row => row[".id"] === reference || row.name === reference)
if (found.length !== 1 || stack.length >= 16 || stack.includes(found[0][".id"])) throw new McpError("STATE_UNAVAILABLE", "Не подтверждён граф шаблонов BGP", 409)
const row = found[0], cached = usedTemplates.get(row[".id"])
if (typeof row.name !== "string" || !row.name.trim() || row.name.length > 100) throw new McpError("STATE_UNAVAILABLE", "Не подтверждено имя шаблона BGP", 409)
if (cached) return cached.mode
if (usedTemplates.size >= 100) throw new McpError("LIMIT_EXCEEDED", "Граф BGP превышает 100 шаблонов", 409)
const [effective] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([row], nativeId => client.post("/routing/bgp/template/get", { ".id": nativeId, "value-name": "disabled" }))
if (!["false", "no"].includes(String(effective.disabled))) throw new McpError("STATE_CONFLICT", "Используемый шаблон BGP отключён или его состояние неизвестно", 409)
const setting = bgpRedistributionMode(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": "output.redistribute" }))
const refs = bgpTemplateReferences(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": "templates" }))
const parents: BgpExportMode[] = []
for (const ref of refs) parents.push(await resolveTemplate(ref, [...stack, row[".id"]]))
if (new Set(parents).size > 1) throw new McpError("STATE_UNAVAILABLE", "Шаблоны BGP задают неоднозначный набор источников", 409)
const result = setting ?? parents[0] ?? "disabled"
const stable = { ...Object.fromEntries(Object.entries(effective).filter(([key]) => !["inactive", "invalid", "running"].includes(key) && !key.startsWith("debug."))), ".id": row[".id"], disabled: "false" }
usedTemplates.set(row[".id"], { row: stable, mode: result, references: refs, localMode: setting })
return result
}
const templateRefs = bgpTemplateReferences(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": "templates" }))
const inherited: BgpExportMode[] = []
for (const ref of templateRefs) inherited.push(await resolveTemplate(ref))
if (new Set(inherited).size > 1) throw new McpError("STATE_UNAVAILABLE", "BGP-подключение наследует неоднозначные источники", 409)
const inheritedMode = inherited[0] ?? "disabled", currentMode = localMode ?? inheritedMode
if (mode === "disabled" && currentMode !== "disabled" && inheritedMode !== "disabled") throw new McpError("STATE_CONFLICT", "Очистка output.redistribute может вернуть экспорт из шаблона; сначала измените наследование отдельным планом", 409)
const connection = parseState("static/dynamic, disabled и явный instance/VRF/table", () => bgpExportConnection(hydrated, currentMode))
const sourceModes = input.sourceModes === undefined ? modes([currentMode, mode]) : modes(z.array(modeSchema).min(1).max(3).parse(input.sourceModes))
if (!sourceModes.includes(currentMode) || !sourceModes.includes(mode)) throw new McpError("STATE_CONFLICT", "Набор источников экспорта изменился", 409)
const parentIds = ["bgp_instance", "vrf", "routing_table", "ipv4_address", "ipv6_address", "routing_filter"]
const parents = await Promise.all(parentIds.map(resource => readRosSnapshot(serverId, resource)))
if (parents.some(snapshot => snapshot.rows.length > 1000)) throw new McpError("LIMIT_EXCEEDED", "Каталог зависимостей BGP превышает 1000 объектов", 409)
if (parents.filter(snapshot => ["ipv4_address", "ipv6_address", "routing_filter"].includes(snapshot.resource)).some(snapshot => snapshot.rows.some(row => typeof row[".id"] !== "string" || !row[".id"].trim() || row[".id"].length > 200))) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены native IDs адресов или фильтров BGP", 409)
const rows = (resource: string) => parents.find(snapshot => snapshot.resource === resource)!.rows
const unique = (resource: string, name: unknown) => {
const found = rows(resource).filter(row => row.name === name)
if (found.length !== 1 || typeof found[0][".id"] !== "string" || found[0].disabled === "true") throw new McpError("STATE_CONFLICT", "Не подтверждена зависимость экспорта BGP: " + resource, 409)
return found[0]
}
const instance = unique("bgp_instance", connection.instance), vrf = unique("vrf", connection.vrf), table = unique("routing_table", connection["routing-table"])
if (instance.vrf !== connection.vrf || instance["routing-table"] !== connection["routing-table"] || vrf.name !== table.name) throw new McpError("STATE_CONFLICT", "Экспорт требует одинаковые явные instance/connection VRF и таблицу", 409)
const [v4, v6] = await Promise.all([client.get<unknown>("/ip/route"), client.get<unknown>("/ipv6/route")])
const sources = [...parseState("IPv4 source origins/table/prefixes", () => bgpExportSources(v4, String(table.name), sourceModes, 4)), ...parseState("IPv6 source origins/table/prefixes", () => bgpExportSources(v6, String(table.name), sourceModes, 6))]
if (sources.length > 500) throw new McpError("LIMIT_EXCEEDED", "Экспорт BGP превышает 500 исходных маршрутов", 409)
const objects: ChangeEvidence["objects"] = [
{ kind: "bgp-export", id, serverId }, { kind: "bgp_connection", id, serverId },
{ kind: "bgp_instance", id: String(instance[".id"]), serverId }, { kind: "vrf", id: String(vrf[".id"]), serverId }, { kind: "routing_table", id: String(table[".id"]), serverId },
...sources.map(row => ({ kind: row.family === 4 ? "ipv4_route" : "ipv6_route", id: row.id, serverId })),
...parents.filter(snapshot => ["ipv4_address", "ipv6_address", "routing_filter"].includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({ kind: snapshot.resource, id: String(row[".id"]), serverId }))),
...[...usedTemplates.keys()].map(nativeId => ({ kind: "bgp_template", id: nativeId, serverId })),
]
if (objects.some(row => !row.id || row.id === "undefined") || objects.length > 1000) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены зависимости экспорта BGP", 409)
const parentProof = parents.map(snapshot => ({ resource: snapshot.resource, rows: ["bgp_instance", "vrf", "routing_table"].includes(snapshot.resource) ? snapshot.rows.filter(row => row[".id"] === ({ bgp_instance: instance, vrf, routing_table: table } as Record<string, ConfigurationRow>)[snapshot.resource][".id"]) : snapshot.rows }))
const templateProof = [...usedTemplates.values()].sort((a, b) => a.row[".id"].localeCompare(b.row[".id"]))
const before = { serverId, serverProof, id, instance: connection.instance, vrf: connection.vrf, table: connection["routing-table"], mode: currentMode, localMode, inheritedMode,
templates: templateProof.map(value => ({ id: value.row[".id"], name: value.row.name, mode: value.mode })),
sourceModes, sources: sources.map(({ proof: _proof, ...row }) => row),
connectionProof: await configurationProof(serverId, id, connection), sourceProof: await privateSecretFingerprint(fingerprint({ serverProof, parentProof, sources, templateProof }), `bgp-export-sources:${serverId}:${id}`) }
return { server, client, connection, before, objects }
}
async function state(input: Record<string, unknown>) {
try { return await readState(input) }
catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены effective export, происхождение маршрутов или явный контекст BGP", 409) }
}
async function current(input: Record<string, unknown>) { return (await state(input)).before }
export function registerBgpExportActions(register: typeof add) {
registerMcpService(service, {
current,
execute: async input => {
const before = await state(input)
if (fingerprint(before.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Подключение или источники BGP изменились после подтверждения", 409)
const mode = modeSchema.parse(input.mode), expected = bgpExportConnection({ ...before.connection, ".id": String(input.id) }, mode)
const receipt = { mode, connectionProof: await configurationProof(Number(input.serverId), String(input.id), expected), sourceProof: before.before.sourceProof, nativeDispatch: before.before.mode !== mode, forwardingVerified: false }
if (before.before.mode !== mode) await executeGuardedApplicationWrite({ kind: "bgp-export", id: String(input.id) }, [Number(input.serverId)], async () => {
const live = await state(input)
if (fingerprint(live.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Источники BGP изменились перед записью", 409)
await live.client.patch(`/routing/bgp/connection/${encodeURIComponent(String(input.id))}`, { "output.redistribute": nativeBgpExportValue(mode) })
}, async () => before.objects)
return receipt
},
verify: async (input, result) => {
const expected = result as { mode: string; connectionProof: string; sourceProof: string }, saved = (await state(input)).before
return saved.mode === expected.mode && saved.connectionProof === expected.connectionProof && saved.sourceProof === expected.sourceProof
},
})
register("routing.bgp.export", "mm_bgp_export_plan", "Человеческий план перераспределения connected/static в BGP с проверкой источников; disabled отключает только перераспределение", "bgp", ["mm:bgp:write", "mm:network:read", "mm:filters:read"], "write", "servers", schema, {
prepare: async input => {
const initial = await state(input), before = initial.before, request = { ...input, sourceModes: before.sourceModes, beforeFingerprint: fingerprint(before), expectedServerProof: before.serverProof }
return { target: { scope: "servers", serverIds: [Number(input.serverId)] }, objects: initial.objects, snapshots: [], before,
steps: [{ kind: "service", service, input: request }], mutationCost: 1, requiresHuman: true, verificationScope: "configuration",
warnings: ["Изменяется output.redistribute одного BGP-подключения. Connected/static экспортируют подходящие маршруты его явной таблицы; это не список разрешённых префиксов.",
"Существующие input/output filters не изменяются; они могут разрешать или отклонять экспорт. Их конфигурация и исходные маршруты запечатаны в плане.",
"Отключается только перераспределение connected/static. Output networks и default-originate не меняются; очистка в активный inherited export из шаблона отклоняется.",
"Включение экспорта требует отдельного человеческого подтверждения. Проверяется конфигурация, обмен префиксами и forwarding требуют отдельных наблюдений.",
"Поля TCP MD5 и исходные данные фильтров не выводятся: приватные proof привязаны к закрытому серверному ключу. Неопределённая запись не повторяется автоматически."] }
},
})
}
@@ -0,0 +1,125 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { readStaticBgpConnection } from "../../services/bgp-connection-read.js"
import { nativeBgpRows, bgpTemplateReferences, type NativeBgpRow } from "../../services/bgp-export-model.js"
import { bgpFilterName, bgpFilterProperty, effectiveBgpFilter, stableBgpPolicyRow, type BgpFilterDirection } from "../../services/bgp-filter-binding-model.js"
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
import { executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
import { canonicalVrfMembers } from "../../services/vrf-members.js"
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
import { routerClient } from "./resources.js"
import { registerMcpService } from "./service-registry.js"
import type { ChangeEvidence } from "./action-types.js"
import type { add } from "./actions.js"
const name = z.string().max(100).refine(value => !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value))
const schema = z.object({ serverId: z.number().int().positive().safe(), id: z.string().regex(/^\*[A-Za-z0-9]+$/).max(200), direction: z.enum(["input", "output"]), chain: name }).strict()
const service = "routing.bgp.filter.bind"
const proof = (sid: number, id: string, domain: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `bgp-filter-binding:${sid}:${id}:${domain}`)
async function readState(input: Record<string, unknown>) {
const serverId = Number(input.serverId), id = String(input.id), desired = String(input.chain), property = bgpFilterProperty(input.direction as BgpFilterDirection)
const { server, client } = await routerClient(serverId), serverProof = await proof(serverId, id, "server", server)
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials BGP изменились; новый источник не опрашивается", 409)
const { connection, connections } = await readStaticBgpConnection(client, id)
if (connections.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог BGP превышает 1000 подключений", 409)
for (const key of ["instance", "vrf", "routing-table"]) if (connection[key] === undefined || connection[key] === "") {
const value = bgpFilterName(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": key }))
if (!value) throw new McpError("STATE_UNAVAILABLE", "Не подтверждён effective контекст BGP: " + key, 409)
connection[key] = value
}
const templates = nativeBgpRows(await client.get("/routing/bgp/template"))
if (templates.length > 100) throw new McpError("LIMIT_EXCEEDED", "Каталог BGP templates превышает 100", 409)
const used = new Map<string, { row: NativeBgpRow; local: string | null; references: string[]; effective: string }>()
const resolve = async (reference: string, stack: string[] = []): Promise<string> => {
const found = templates.filter(row => row[".id"] === reference || row.name === reference)
if (found.length !== 1 || stack.length >= 16 || stack.includes(found[0][".id"])) throw new Error("BGP template graph not confirmed")
const row = found[0], cached = used.get(row[".id"])
if (cached) return cached.effective
const [hydrated] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([row], nativeId => client.post("/routing/bgp/template/get", { ".id": nativeId, "value-name": "disabled" }))
if (!["false", "no"].includes(String(hydrated.disabled))) throw new Error("BGP template is disabled or unknown")
const local = bgpFilterName(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": property }))
const references = bgpTemplateReferences(await client.post("/routing/bgp/template/get", { ".id": row[".id"], "value-name": "templates" }))
const parents = []; for (const ref of references) parents.push(await resolve(ref, [...stack, row[".id"]]))
const effective = effectiveBgpFilter(local, parents)
used.set(row[".id"], { row: { ...stableBgpPolicyRow(hydrated), ".id": row[".id"] }, local, references, effective }); return effective
}
const localChain = bgpFilterName(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": property }))
const references = bgpTemplateReferences(await client.post("/routing/bgp/connection/get", { ".id": id, "value-name": "templates" }))
const parents = []; for (const ref of references) parents.push(await resolve(ref))
const inheritedChain = new Set(parents).size > 1 ? null : parents[0] ?? "", effectiveChain = effectiveBgpFilter(localChain, parents)
if (desired === "" && inheritedChain !== "") throw new McpError("STATE_CONFLICT", "Очистка local binding включила бы inherited filter; нужен отдельный план шаблона", 409)
const snapshots = await Promise.all([["bgp_instance", "/routing/bgp/instance"], ["vrf", "/ip/vrf"], ["routing_table", "/routing/table"]].map(async ([resource, path]) => ({ resource, rows: nativeBgpRows(await client.get(path)) })))
if (snapshots.some(row => row.rows.length > 1000)) throw new Error("BGP context exceeds 1000 rows")
const unique = (kind: string, reference: unknown) => {
const rows = snapshots.find(row => row.resource === kind)!.rows.filter(row => row.name === reference || row[".id"] === reference)
if (rows.length !== 1 || rows[0].disabled === "true" || [true, "true", "yes"].includes(rows[0].invalid as boolean | string)) throw new Error("BGP context not confirmed")
return rows[0]
}
const instance = unique("bgp_instance", connection.instance), vrf = unique("vrf", connection.vrf), table = unique("routing_table", connection["routing-table"])
const [effectiveInstance] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([instance], nativeId => client.post("/routing/bgp/instance/get", { ".id": nativeId, "value-name": "disabled" }))
if (!["false", "no"].includes(String(effectiveInstance.disabled))) throw new Error("BGP instance disabled/unknown")
Object.assign(instance, effectiveInstance)
for (const key of ["vrf", "routing-table"]) if (instance[key] === undefined || instance[key] === "") {
const value = bgpFilterName(await client.post("/routing/bgp/instance/get", { ".id": String(instance[".id"]), "value-name": key }))
if (!value) throw new Error("BGP instance effective context not confirmed")
instance[key] = value
}
if (unique("vrf", instance.vrf)[".id"] !== vrf[".id"] || unique("routing_table", instance["routing-table"])[".id"] !== table[".id"] || vrf.name !== table.name) throw new Error("BGP instance/connection contexts disagree")
const filters = nativeBgpRows(await client.get("/routing/filter/rule"))
if (filters.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог BGP filters превышает 1000 строк", 409)
const selected = filters.filter(row => row.chain === desired)
if (desired && !selected.length) throw new McpError("NOT_FOUND", "Выбранная цепочка фильтров не найдена", 404)
if (selected.some(row => [true, "true", "yes"].includes(row.invalid as boolean | string))) throw new McpError("STATE_CONFLICT", "Выбранная цепочка содержит invalid rule", 409)
const effectiveFilters = desired ? await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>(selected, nativeId => client.post("/routing/filter/rule/get", { ".id": nativeId, "value-name": "disabled" })) : []
const protections = (await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(serverId)])).rows
if (protections.length > 1000) throw new Error("BGP protection closure exceeds limit")
const objects: ChangeEvidence["objects"] = [{ kind: "bgp-policy", id, serverId }, { kind: "bgp_connection", id, serverId },
...[["bgp_instance", instance], ["vrf", vrf], ["routing_table", table]].map(([kind, row]) => ({ kind: String(kind), id: String((row as Record<string, unknown>)[".id"]), serverId })),
...filters.map(row => ({ kind: "routing_filter", id: row[".id"], serverId })), ...[...used.keys()].map(nativeId => ({ kind: "bgp_template", id: nativeId, serverId })),
...protections.map(({ object_ref: ref }) => ({ kind: ref.kind, id: ref.id, serverId }))]
const uniqueObjects = [...new Map(objects.map(row => [fingerprint(row), row])).values()]
if (uniqueObjects.length > 1000) throw new Error("BGP dependency closure exceeds limit")
const canonicalConnection = { ...stableBgpPolicyRow(connection), [property]: effectiveChain }
const dependencyProof = await proof(serverId, id, "dependencies", { serverProof, otherConnections: connections.filter(row => row[".id"] !== id).map(stableBgpPolicyRow), instance: stableBgpPolicyRow(instance), vrf: { ...stableBgpPolicyRow(vrf), ...(vrf.interfaces !== undefined ? { interfaces: canonicalVrfMembers(vrf.interfaces) } : {}) }, table: stableBgpPolicyRow(table),
filters: filters.map(stableBgpPolicyRow), used: [...used.values()], references, protections })
const before = { serverId, id, direction: input.direction, property, instance: instance.name, vrf: vrf.name, table: table.name, localChain, inheritedChain, effectiveChain,
selectedRuleCount: selected.length, selectedEnabledRuleCount: effectiveFilters.filter(row => ["false", "no"].includes(String(row.disabled))).length, serverProof, dependencyProof,
connectionProof: await proof(serverId, id, "connection", canonicalConnection) }
return { server, client, connection: canonicalConnection, before, objects: uniqueObjects }
}
async function state(input: Record<string, unknown>) {
try { return await readState(input) } catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены BGP binding, templates или routing dependencies", 409) }
}
export function registerBgpFilterBindingActions(register: typeof add) {
registerMcpService(service, {
current: async input => (await state(input)).before,
execute: async input => {
const initial = await state(input)
if (fingerprint(initial.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "BGP policy изменилась после подтверждения", 409)
const desired = String(input.chain), nativeDispatch = desired ? initial.before.localChain !== desired : initial.before.localChain !== null && initial.before.localChain !== ""
const result = { desired, nativeDispatch, dependencyProof: initial.before.dependencyProof, connectionProof: await proof(initial.server.id, String(input.id), "connection", { ...initial.connection, [initial.before.property]: desired }), forwardingVerified: false }
await executeGuardedApplicationWrite({ kind: "bgp-policy", id: String(input.id) }, [initial.server.id], async () => {
const live = await state(input)
if (fingerprint(live.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "BGP dependencies изменились перед dispatch", 409)
if (nativeDispatch) await live.client.patch("/routing/bgp/connection/" + encodeURIComponent(String(input.id)), { [live.before.property]: desired })
}, async () => initial.objects)
return result
},
verify: async (input, result) => {
const receipt = result as { desired: string; connectionProof: string; dependencyProof: string }, after = (await state(input)).before
return after.effectiveChain === receipt.desired && (receipt.desired ? after.localChain === receipt.desired : !after.localChain) && after.connectionProof === receipt.connectionProof && after.dependencyProof === receipt.dependencyProof
},
})
register("routing.bgp.filter.bind", "mm_bgp_filter_binding_plan", "Человеческий план локальной input/output привязки BGP к существующей цепочке; пустая chain очищает local binding", "bgp", ["mm:bgp:write", "mm:filters:read", "mm:network:read"], "write", "servers", schema, {
prepare: async input => {
const initial = await state(input)
return { target: { scope: "servers", serverIds: [initial.server.id] }, objects: initial.objects, snapshots: [], before: initial.before, requiresHuman: true, verificationScope: "configuration", mutationCost: 1,
steps: [{ kind: "service", service, input: { ...input, beforeFingerprint: fingerprint(initial.before), expectedServerProof: initial.before.serverProof } }],
warnings: ["Меняется одна локальная привязка BGP; цепочка не создаётся и её код не изменяется. Изменение относится ко всем AFI/префиксам этого peer и может повлиять на выбор/экспорт маршрутов.",
"Фильтры/templates и контекст VRF/таблицы запечатаны. Очистка в непустой inherited filter запрещена; шаблоны не изменяются неявно.",
"Семантика jumps/opaque правил и доступность пути этим планом не подтверждаются. Пустая или полностью отключённая цепочка может отклонить все префиксы.",
"Только подтверждение человеком; после partial/unknown нужен новый анализ. Автоматического повтора, rollback или refresh BGP не выполняется."] }
},
})
}
@@ -0,0 +1,48 @@
import { z } from "zod"
import { hasPermission } from "../../lib/permissions.js"
import { bgpPrefixRows } from "../../services/bgp-prefix-model.js"
import { nativeBgpRows } from "../../services/bgp-export-model.js"
import { getServerRowById } from "../servers/repository/servers-repository.js"
import { routerClient } from "./resources.js"
import { effectiveMcpConnection } from "./store.js"
import { fingerprint, McpError } from "./security.js"
import type { ActionContext } from "./action-types.js"
import type { add } from "./actions.js"
const permissions = ["mm:bgp:read", "mm:network:read"]
async function access(context: ActionContext) {
if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Чтение BGP префиксов отменено", 409)
const current = await effectiveMcpConnection(context.principal)
if (permissions.some(permission => !hasPermission(current.owner_permissions, permission) || !hasPermission(current.policy.permissions, permission))) throw new McpError("PERMISSION_DENIED", "Право чтения BGP/сети отозвано", 403)
if (fingerprint(current.policy) !== fingerprint(context.connection.policy)) throw new McpError("RESET_REQUIRED", "Область чтения BGP изменилась", 409)
}
export function registerBgpPrefixActions(register: typeof add) {
register("routing.bgp.prefixes.read", "mm_bgp_prefixes_get", "Наблюдаемые BGP-префиксы IPv4/IPv6 в явной таблице; active nullable, без доказательства forwarding или attribution к peer", "bgp", permissions, "read", "servers", z.object({
serverId: z.number().int().positive().safe(), family: z.enum(["ipv4", "ipv6"]).default("ipv4"),
table: z.string().trim().min(1).max(100).refine(value => !/[\u0000-\u001f\u007f]/.test(value)).default("main"),
offset: z.number().int().min(0).max(10000).default(0), limit: z.number().int().min(1).max(200).default(100),
}).strict(), { read: async (input, context) => {
await access(context)
const { server, client } = await routerClient(Number(input.serverId)), startedAt = new Date().toISOString(), table = String(input.table)
const tableState = async () => {
await access(context)
const selected = nativeBgpRows(await client.get("/routing/table", 10000, context.signal)).filter(row => row.name === table)
if (selected.length !== 1) throw new McpError("STATE_UNAVAILABLE", "Таблица BGP не найдена или неоднозначна", 409)
return fingerprint(selected)
}
try {
const before = await tableState()
await access(context)
const family = input.family === "ipv4" ? 4 : 6
const rows = bgpPrefixRows(await client.get(`${family === 4 ? "/ip/route" : "/ipv6/route"}?routing-table=${encodeURIComponent(table)}`, 10000, context.signal), table, family)
if (before !== await tableState()) throw new McpError("STATE_CONFLICT", "Таблица BGP изменилась во время чтения", 409)
const fresh = await getServerRowById(server.id)
if (!fresh || fingerprint(fresh) !== fingerprint(server)) throw new McpError("STATE_CONFLICT", "Источник BGP изменился во время чтения", 409)
await access(context)
const offset = Number(input.offset), limit = Number(input.limit), items = rows.slice(offset, offset + limit)
return { serverId: server.id, family: input.family, table, items, total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null, incomplete: items.length < rows.length,
startedAt, observedAt: new Date().toISOString(), timestampKind: "backend-receipt", fingerprint: fingerprint(rows), source: "routeros-route-view", atomicSnapshot: false,
sessionAttributionVerified: false, advertisedRoutesVerified: false, forwardingVerified: false }
} catch (error) { await access(context); if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "BGP route view недоступен, неоднозначен или превышает 10000 строк; успешный пустой ответ не подтверждён", 409) }
} })
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod"
import { and, eq, inArray, asc } from "drizzle-orm"
import { db } from "../../db/index.js"
import { servers } from "../../db/schema.js"
import type { ActionContext } from "./action-types.js"
export const catalogPaging = z.object({ serverId: z.number().int().positive().safe().optional(), serverOffset: z.number().int().min(0).max(10000).default(0), serverLimit: z.number().int().min(1).max(10).default(5), offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(200).default(100) }).strict()
/** Resolve only the policy already intersected with the credential's grant,
* before constructing clients or polling a device. */
export async function catalogDevices(input: Record<string, unknown>, context: ActionContext, options: { includeDisabled?: boolean } = {}) {
const ids = input.serverId ? [Number(input.serverId)] : context.connection.policy.scope.serverIds
const eligible = ids.length ? await db.select().from(servers).where(and(inArray(servers.id, ids), ...(options.includeDisabled ? [] : [eq(servers.enabled, true)]))).orderBy(asc(servers.id)) : []
const start = Number(input.serverOffset), selected = eligible.slice(start, start + Number(input.serverLimit))
return { selected, serverIds: selected.map(row => row.id), totalServers: eligible.length, nextServerOffset: start + selected.length < eligible.length ? start + selected.length : null, incomplete: selected.length < eligible.length }
}
+8
View File
@@ -0,0 +1,8 @@
import type { ChangeEvidence } from "./action-types.js"
import { McpError } from "./security.js"
export function changeMutationCost(evidence: Pick<ChangeEvidence, "steps" | "mutationCost">): number {
const cost = evidence.mutationCost
if (cost !== undefined && (!Number.isSafeInteger(cost) || cost < 1 || cost > 10000 || cost < evidence.steps.length)) throw new McpError("STATE_CONFLICT", "Не подтверждена стоимость пакетного плана", 409)
return Math.max(1, evidence.steps.length, cost ?? 0)
}
@@ -0,0 +1,335 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { readFile, writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { findAction, listActions } from "./actions.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import { MikrotikClient, MikrotikError } from "../../services/mikrotik.js"
import { McpError } from "./security.js"
import type { AuthUser } from "../../lib/permissions.js"
import { acceptChrOverlay, cleanupChrOverlay } from "./chr-overlay-acceptance.js"
import { cleanupChrCommunityGatewayResolvers } from "./chr-bgp-community-acceptance.js"
import { cleanupChrBgpFixture } from "./chr-bgp-acceptance.js"
import { chrNativeFailure } from "./chr-native-failure.js"
import { readChrRecoverySource } from "./chr-recovery-source.js"
import { cleanupChrOspf } from "./chr-ospf-acceptance.js"
import { chrFixtureVrf, chrOspfForwarding } from "./chr-fixture-context.js"
import { acceptChrOspfPassive } from "./chr-ospf-passive.js"
import { acceptChrWireguardInterface } from "./chr-wireguard-interface.js"
import { acceptChrWireguardPeer } from "./chr-wireguard-peer.js"
import { acceptChrWireguardInterfaceUpdate } from "./chr-wireguard-interface-update.js"
// Explicit lab allowlist: cannot accidentally point this mutating test at production.
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.ok(hosts.length > 0 && hosts.length <= 2 && new Set(hosts).size === hosts.length)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const username = process.env.MMAPP_TEST_CHR_USER, password = process.env.MMAPP_TEST_CHR_PASSWORD
assert.ok(username && password, "Explicit lab credentials required")
const tunnelPorts = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (tunnelPorts) assert.ok(tunnelPorts.length === hosts.length && tunnelPorts.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
const actor: AuthUser = { id: "chr-acceptance-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:bgp:admin", "mm:servers:admin", "mm:filters:read"], expiresAt: Date.now() + 3600000 }
const marker = "mcp-test-" + randomUUID().slice(0, 8)
const routingAcceptance = process.env.MMAPP_TEST_CHR_ROUTING === "true"
const overlayAcceptance = process.env.MMAPP_TEST_CHR_OVERLAY === "true"
const ospfAcceptance = process.env.MMAPP_TEST_CHR_OSPF === "true"
const ospfSourceAcceptance = process.env.MMAPP_TEST_CHR_OSPF_SOURCES === "true"
const ospfForwarding = chrOspfForwarding()
const ospfPassiveOnly = process.env.MMAPP_TEST_CHR_OSPF_PASSIVE_ONLY === "true"
const wireguardPeerOnly = process.env.MMAPP_TEST_CHR_WG_PEER_ONLY === "true"
const wireguardPeerUpdate = process.env.MMAPP_TEST_CHR_WG_PEER_UPDATE === "true"
const wireguardInterfaceDelete = process.env.MMAPP_TEST_CHR_WG_INTERFACE_DELETE === "true"
const wireguardInterfaceUpdate = process.env.MMAPP_TEST_CHR_WG_INTERFACE_UPDATE === "true"
assert.ok(!wireguardInterfaceDelete || wireguardPeerOnly && !wireguardPeerUpdate, "WireGuard interface cascade requires its isolated peer fixture mode")
assert.ok(!wireguardPeerUpdate || wireguardPeerOnly, "WireGuard peer update requires its isolated peer scenario")
assert.ok(!ospfPassiveOnly || !ospfAcceptance && !overlayAcceptance && !routingAcceptance, "Passive-only mode must be isolated from forwarding scenarios")
assert.ok(!ospfSourceAcceptance || ospfAcceptance, "Source-bound OSPF acceptance requires OSPF")
const ecmpAcceptance = process.env.MMAPP_TEST_CHR_ECMP === "true"
const bgpAcceptance = process.env.MMAPP_TEST_CHR_BGP === "true"
const bgpExportAcceptance = process.env.MMAPP_TEST_CHR_BGP_EXPORT === "true"
const bgpForwardAcceptance = process.env.MMAPP_TEST_CHR_BGP_FORWARD === "true"
const bgpFilterBindingAcceptance = process.env.MMAPP_TEST_CHR_BGP_FILTER_BINDING === "true"
const bgpCommunityAcceptance = process.env.MMAPP_TEST_CHR_BGP_COMMUNITY === "true"
assert.ok(!wireguardPeerOnly || hosts.length === 1 && !ospfPassiveOnly && !overlayAcceptance && !ospfAcceptance && !routingAcceptance && !bgpAcceptance && !ecmpAcceptance, "WireGuard peer mode requires one isolated device")
assert.ok(!wireguardInterfaceUpdate || !wireguardPeerOnly && !wireguardInterfaceDelete && !wireguardPeerUpdate && !ospfPassiveOnly && !overlayAcceptance && !ospfAcceptance && !routingAcceptance && !bgpAcceptance && !ecmpAcceptance, "WireGuard interface update requires its isolated disabled fixture mode")
assert.ok(!ospfForwarding || ospfSourceAcceptance && !bgpAcceptance && !ecmpAcceptance, "OSPF forwarding requires its isolated source-bound scenario")
assert.ok(process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER !== "true" || bgpCommunityAcceptance && process.env.MMAPP_TEST_CHR_COMMUNITY_LITERAL_GATEWAY !== "true")
assert.ok(process.env.MMAPP_TEST_CHR_BGP_BINDING_FAULTS !== "false" || bgpCommunityAcceptance)
const overlayCleanupOnly = process.env.MMAPP_TEST_CHR_OVERLAY_CLEANUP_ONLY === "true"
assert.ok(!overlayCleanupOnly || overlayAcceptance && process.env.MMAPP_TEST_CHR_OVERLAY_CLEANUP_MARKER)
if (bgpFilterBindingAcceptance) actor.permissions.push("mm:filters:write")
assert.ok(!ospfAcceptance || overlayAcceptance, "OSPF acceptance requires the isolated overlay")
assert.ok(!ecmpAcceptance || overlayAcceptance, "ECMP acceptance requires the isolated overlay")
assert.ok(!bgpAcceptance || overlayAcceptance, "BGP acceptance requires the isolated overlay")
assert.ok(!bgpExportAcceptance || bgpAcceptance, "BGP prefix acceptance requires BGP and the isolated overlay")
assert.ok(!bgpForwardAcceptance || bgpExportAcceptance, "BGP forwarding probes require prefix acceptance")
assert.ok(!bgpFilterBindingAcceptance || bgpForwardAcceptance, "BGP filter-binding acceptance requires prefix and packet acceptance")
assert.ok(!bgpCommunityAcceptance || bgpFilterBindingAcceptance, "Community propagation requires filter-binding and packet acceptance")
const requestGapText = process.env.MMAPP_TEST_CHR_REQUEST_GAP_MS ?? "0"
assert.ok(["0", "100", "250", "500"].includes(requestGapText))
const requestGapMs = Number(requestGapText)
let pacing = Promise.resolve(), lastRequestAt = 0
const pace = async () => {
if (!requestGapMs) return
const ready = pacing.then(async () => { const wait = requestGapMs - (Date.now() - lastRequestAt); if (wait > 0) await delay(wait); lastRequestAt = Date.now() })
pacing = ready.catch(() => {}); await ready
}
const report: Record<string, unknown> = { startedAt: new Date().toISOString(), marker, tools: listActions().length, nativeRequestGapMs: requestGapMs, checks: [], complete: false, ...(ospfSourceAcceptance ? { optimizerRevision: findAction("mm_ospf_optimizer_plan").operation.version, measurementRevision: findAction("mm_routing_measurement_plan").operation.version, sourceMigration: "0029_mcp_routing_sources" } : {}) }
const checks = report.checks as Record<string, unknown>[]
if (wireguardPeerOnly) Object.assign(report, { fixtureSchema: wireguardPeerUpdate ? "wg-peer-update-v1" : "wg-peer-delete-v1", ...(wireguardPeerUpdate ? { businessUpdateRevision: findAction("mm_wireguard_peer_edit_plan").operation.version, nativeUpdateRevision: findAction("mm_wireguard_peer_update_plan").operation.version } : {}), businessDeleteRevision: findAction("mm_wireguard_peer_remove_plan").operation.version, nativeDeleteRevision: findAction("mm_wireguard_peer_delete_plan").operation.version })
if (wireguardInterfaceDelete) Object.assign(report, { fixtureSchema: "wg-interface-delete-v1", businessInterfaceDeleteRevision: findAction("mm_wireguard_interface_remove_plan").operation.version, nativeInterfaceDeleteRevision: findAction("mm_wireguard_delete_plan").operation.version })
if (ospfForwarding) Object.assign(report, { ospfForwarding: true, fixtureSchema: "ospf-forward-v1", routeProofOperation: "routing.ospf.route.proof.read", vrfNames: [1, 2].map(id => chrFixtureVrf(marker, id)) })
const reportUrl = new URL(wireguardInterfaceDelete ? "../../../../docs/mcp-chr-wireguard-interface-delete-acceptance.json" : wireguardPeerUpdate ? "../../../../docs/mcp-chr-wireguard-peer-update-acceptance.json" : wireguardPeerOnly ? "../../../../docs/mcp-chr-wireguard-peer-delete-acceptance.json" : ospfPassiveOnly ? "../../../../docs/mcp-chr-ospf-passive-toggle-acceptance.json" : ospfForwarding ? (overlayCleanupOnly ? "../../../../docs/mcp-chr-ospf-forward-recovery.json" : "../../../../docs/mcp-chr-ospf-forward-acceptance.json") : ospfSourceAcceptance ? (overlayCleanupOnly ? "../../../../docs/mcp-chr-ospf-source-recovery.json" : "../../../../docs/mcp-chr-ospf-source-acceptance.json") : bgpCommunityAcceptance ? "../../../../docs/mcp-chr-bgp-community-acceptance.json" : bgpFilterBindingAcceptance ? "../../../../docs/mcp-chr-bgp-filter-binding-acceptance.json" : bgpForwardAcceptance ? "../../../../docs/mcp-chr-bgp-forward-acceptance.json" : bgpExportAcceptance ? "../../../../docs/mcp-chr-bgp-prefix-acceptance.json" : bgpAcceptance ? "../../../../docs/mcp-chr-bgp-acceptance.json" : ecmpAcceptance ? "../../../../docs/mcp-chr-ecmp-acceptance.json" : ospfAcceptance ? "../../../../docs/mcp-chr-ospf-acceptance.json" : overlayAcceptance ? "../../../../docs/mcp-chr-overlay-acceptance.json" : routingAcceptance ? "../../../../docs/mcp-chr-path-acceptance.json" : "../../../../docs/mcp-chr-acceptance.json", import.meta.url)
let checkpointQueue = Promise.resolve()
const wgUpdateReportUrl = new URL(`../../../../docs/mcp-chr-wg-interface-update-${marker.slice(-8)}-acceptance.json`, import.meta.url)
if (wireguardInterfaceUpdate) Object.assign(report, { fixtureSchema: "wg-interface-update-v2", privateProofDomain: "wireguard-interface-update-v2", businessRevision: findAction("mm_wireguard_interface_edit_plan").operation.version, nativeAliasRevision: findAction("mm_wireguard_update_plan").operation.version, forwardingVerified: false })
const checkpoint = () => {
const payload = JSON.stringify(report, null, 2) + "\n"
const pending = checkpointQueue.catch(() => {}).then(() => writeFile(wireguardInterfaceUpdate ? wgUpdateReportUrl : overlayCleanupOnly && !ospfSourceAcceptance ? new URL("../../../../docs/mcp-chr-overlay-recovery.json", import.meta.url) : reportUrl, payload))
checkpointQueue = pending; return pending
}
const originalPost = MikrotikClient.prototype.post, originalPatch = MikrotikClient.prototype.patch
const originalGet = MikrotikClient.prototype.get, originalDelete = MikrotikClient.prototype.delete
const originalPut = MikrotikClient.prototype.put
const captureNativeFailure = async (error: unknown, method: string, path: string, startedAt: number) => {
if (checks.filter(row => row.phase === "native-request-failure").length >= 256) return
checks.push(chrNativeFailure(error, method, path, startedAt)); await checkpoint()
}
MikrotikClient.prototype.get = async function<T>(path: string, timeoutMs?: number, signal?: AbortSignal, maxResponseBytes?: number): Promise<T> {
await pace(); const startedAt = Date.now()
try { return await originalGet.call(this, path, timeoutMs, signal, maxResponseBytes) as T } catch (error) { await captureNativeFailure(error, "GET", path, startedAt); throw error }
}
MikrotikClient.prototype.delete = async function(path: string, timeoutMs?: number, beforeDispatch?:()=>Promise<unknown>): Promise<void> {
await pace(); const startedAt = Date.now()
try { const result = await originalDelete.call(this, path, timeoutMs, beforeDispatch); if (wireguardInterfaceDelete) { checks.push({ phase: "wg-cascade-native-delete-ack", path, at: new Date().toISOString() }); await checkpoint() } return result } catch (error) { await captureNativeFailure(error, "DELETE", path, startedAt); throw error }
}
MikrotikClient.prototype.put = async function<T>(path: string, body: Record<string, string>, timeoutMs?: number, beforeDispatch?:()=>Promise<unknown>): Promise<T> {
await pace(); const startedAt = Date.now()
try { return await originalPut.call(this, path, body, timeoutMs, beforeDispatch) as T } catch (error) { await captureNativeFailure(error, "PUT", path, startedAt); throw error }
}
const captureParser = async (error: unknown, path: string) => {
if (!bgpCommunityAcceptance || !(error instanceof MikrotikError) || error.statusCode !== 400 || !path.startsWith("/routing/filter/rule")) return
let detail = ""
try { const parsed = JSON.parse(error.body); if (typeof parsed.detail === "string") detail = parsed.detail } catch { /* Never save headers/raw provider payloads. */ }
detail = detail.replaceAll(password!, "[redacted]").replace(/(?:password|secret|token|private-key)\s*[=:]\s*[^\s;,]+/gi, "[redacted]").slice(0, 500)
checks.push({ phase: "community-owned-parser-error", path, nativeStatus: 400, detail }); await checkpoint()
}
{
MikrotikClient.prototype.post = async function<T>(path: string, body: Record<string, string | string[]>, timeoutMs?: number, signal?: AbortSignal): Promise<T> {
await pace(); const startedAt = Date.now()
try { return await originalPost.call(this, path, body, timeoutMs, signal) as T } catch (error) { await captureNativeFailure(error, "POST", path, startedAt); await captureParser(error, path); throw error }
}
MikrotikClient.prototype.patch = async function<T>(path: string, body: Record<string, string>, timeoutMs?: number, beforeDispatch?:()=>Promise<unknown>): Promise<T> {
await pace(); const startedAt = Date.now()
try { return await originalPatch.call(this, path, body, timeoutMs, beforeDispatch) as T } catch (error) { await captureNativeFailure(error, "PATCH", path, startedAt); await captureParser(error, path); throw error }
}
}
try {
await checkpoint()
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0, "Acceptance requires an empty disposable database")
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) {
const serverId = index + 1
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [serverId, marker, tunnelPorts ? "127.0.0.1" : host, tunnelPorts?.[index] ?? 80, username, password])
}
const operationIds = ["routing.topology.read", "routing.path.verify", "operations.recover", "operations.reconcile", "ros.bridge.list", "ros.bridge.create", "ros.bridge.update", "ros.bridge.delete", "ros.ipv4_route.list", "ros.ipv6_route.list", ...["routing_table", "ipv4_route"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`))]
if (overlayAcceptance) operationIds.push(...["gre", "vrf", "ipv4_address", "ipv6_address"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
if (wireguardPeerOnly) operationIds.push("network.wireguard.peers.delete", "ros.wireguard.create", "ros.wireguard.delete", "ros.wireguard_peer.create", "ros.wireguard_peer.delete")
if (wireguardInterfaceDelete) operationIds.push("network.wireguard.interfaces.delete", ...["ipv4_address", "ipv6_address"].flatMap(resource => ["create", "delete"].map(action => `ros.${resource}.${action}`)))
if (wireguardPeerUpdate) operationIds.push("network.wireguard.peers.update", "ros.wireguard_peer.update")
if (wireguardInterfaceUpdate) operationIds.push("network.wireguard.interfaces.update", "ros.wireguard.create", "ros.wireguard.update", "ros.wireguard.delete")
if (ospfForwarding) operationIds.push("routing.ospf.route.proof.read")
if (ospfAcceptance) operationIds.push("diagnostics.bandwidth", "routing.ospf.optimize", ...["ospf_instance", "ospf_area", "ospf_template", "bfd_configuration"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
if (ospfPassiveOnly) operationIds.push(...["ospf_instance", "ospf_area", "ospf_template", "vrf"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
if (ecmpAcceptance) operationIds.push("routing.ecmp.update", "ros.ipv6_route.create", "ros.ipv6_route.delete")
if (bgpAcceptance) operationIds.push(...["bgp_instance", "bgp_connection"].flatMap(resource => ["create", "update", "delete"].map(action => `ros.${resource}.${action}`)))
if (bgpExportAcceptance) operationIds.push("routing.bgp.export", "routing.bgp.prefixes.read")
if (bgpFilterBindingAcceptance) operationIds.push("routing.bgp.filter.bind", "filters.rules.replace")
if (bgpCommunityAcceptance) operationIds.push("routing.bgp.community.policy")
if (process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER === "true") operationIds.push("ros.ipv6_route.create", "ros.ipv6_route.delete")
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + (bgpCommunityAcceptance || ospfAcceptance ? 2700000 : 1800000)).toISOString(), permissions: ["mm:network:read", "mm:network:write", ...(bgpAcceptance ? ["mm:bgp:read", "mm:bgp:write"] : []), ...(bgpExportAcceptance ? ["mm:filters:read"] : []), ...(bgpFilterBindingAcceptance ? ["mm:filters:write"] : [])], scope: { serverIds: hosts.map((_, index) => index + 1), operationIds, allowGlobal: false }, limits: { callsPerMinute: 120, concurrentReads: 4, mutationsPerHour: bgpCommunityAcceptance ? 240 : ospfAcceptance || ecmpAcceptance || bgpAcceptance ? 160 : 40, serversPerPlan: 2, objectsPerPlan: 100 } }), actor)
const principal = await authenticateMcpToken(issued.token)
const execute = async (tool: string, args: Record<string, unknown>) => {
// Preserve the stage and IDs even if the disposable DB is removed after an
// interrupted acceptance. Never persist tool arguments or provider payloads.
const trace: Record<string, unknown> = { phase: "mcp-operation", tool, serverId: typeof args.serverId === "number" ? args.serverId : undefined, stage: "prepare", state: "preparing" }
checks.push(trace); await checkpoint()
try {
const plan = await invokeMcpTool(principal, tool, args) as { planId: string; state: string }
trace.planId = plan.planId; trace.stage = "approval"
assert.equal(plan.state, "awaiting-approval")
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()))
await approvePlan(plan.planId, actor, true)
trace.stage = "apply"
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
trace.operationId = operation.id; trace.stage = "verification"; trace.state = "running"; await checkpoint()
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
for (let attempt = 0; attempt < (wireguardInterfaceDelete && tool === "mm_wireguard_interface_remove_plan" ? 960 : 240); attempt++) {
const row = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
trace.state = row.state; trace.stage = "terminal"
if (row.state !== "applied") {
const detail = (await pool.query("SELECT error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
trace.errorCode = detail.error_code
console.error({ tool, state: row.state, errorCode: detail.error_code })
if (tool === "mm_routing_table_create_plan") console.error({ tableReadBack: (await readRosSnapshot(Number(args.serverId), "routing_table")).rows.filter(row => row.name === marker) })
}
await checkpoint()
assert.equal(row.state, "applied", `Operation ${operation.id} ended ${row.state}`)
const steps = (await pool.query("SELECT state FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows
assert.ok(steps.length > 0 && steps.every(step => step.state === "verified"))
return operation.id
}
await delay(500)
}
throw new Error(`Operation ${operation.id} remains pending; do not automatically retry writes`)
} catch (error) {
if (["preparing", "running"].includes(String(trace.state))) trace.state = "unconfirmed"
trace.errorCode ??= error instanceof McpError ? error.code : "TEST_STAGE_UNCONFIRMED"
await checkpoint(); throw error
}
}
const cleanupMarker = process.env.MMAPP_TEST_CHR_CLEANUP_MARKER
const overlayCleanupMarker = process.env.MMAPP_TEST_CHR_OVERLAY_CLEANUP_MARKER
let recoveryBaseline: { serverId: number; resource: string; fingerprint: string }[] = []
if (overlayCleanupMarker) {
assert.ok(overlayAcceptance, "Overlay cleanup requires explicit overlay mode")
if (overlayCleanupOnly) {
const recoveryReport = process.env.MMAPP_TEST_CHR_OVERLAY_RECOVERY_REPORT ?? "mcp-chr-bgp-community-interrupted-overlay.json"
assert.match(recoveryReport, /^mcp-chr-[a-z0-9-]+\.json$/)
const previous = JSON.parse(await readFile(new URL("../../../../docs/" + recoveryReport, import.meta.url), "utf8"))
assert.equal(previous.marker, overlayCleanupMarker)
assert.equal(previous.complete, false)
assert.ok(typeof previous.finishedAt === "string" && Number.isFinite(Date.parse(previous.finishedAt)) && Date.parse(previous.finishedAt) <= Date.now(), "Recovery refuses a report whose original process has not finished")
const recoverySource = readChrRecoverySource(previous, overlayCleanupMarker)
assert.equal(recoverySource.ospfForwarding, ospfForwarding, "Recovery fixture mode must match the source report")
recoveryBaseline = recoverySource.baselines
checks.push({ phase: "recovery-source", report: recoveryReport, kind: recoverySource.kind, originalMarker: overlayCleanupMarker, originalFinishedAt: previous.finishedAt, baselines: recoveryBaseline.length }); await checkpoint()
if (recoverySource.kind === "ospf") {
assert.ok(ospfAcceptance, "OSPF recovery requires explicitly scoped OSPF permissions")
await cleanupChrOspf(overlayCleanupMarker, execute)
}
if (bgpAcceptance) await cleanupChrBgpFixture(overlayCleanupMarker, execute)
if (process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER === "true") {
const recordedRoutes = previous.checks.filter((row: { phase?: string }) => row.phase === "community-gateway-resolver-created").flatMap((row: { nativeRoutes?: unknown }) => { assert.ok(Array.isArray(row.nativeRoutes) && row.nativeRoutes.length === 1); return row.nativeRoutes })
await cleanupChrCommunityGatewayResolvers(overlayCleanupMarker, execute, recordedRoutes)
}
}
await cleanupChrOverlay(hosts, overlayCleanupMarker, execute)
checks.push({ interruptedOverlayRemoved: overlayCleanupMarker })
}
if (cleanupMarker) {
assert.match(cleanupMarker, /^mcp-test-[0-9a-f]{8}$/)
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const tables = (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === cleanupMarker)
if (!tables.length) continue
assert.ok(tables.every(row => row.fib === "false"), "Cleanup only accepts isolated non-FIB tables")
assert.ok(!(await client.get<Record<string, string>[]>("/routing/rule")).some(row => row.table === cleanupMarker))
for (const resource of ["ipv4_route", "ipv6_route"]) assert.ok(!(await readRosSnapshot(serverId, resource)).rows.some(row => row["routing-table"] === cleanupMarker), "Interrupted table must be empty before cleanup")
for (const row of tables) await execute("mm_routing_table_delete_plan", { serverId, id: row[".id"] })
checks.push({ host, interruptedRunCleanup: cleanupMarker, emptyNonFibTableRemoved: true })
}
}
if (overlayCleanupOnly) {
for (const baseline of recoveryBaseline) assert.equal((await readRosSnapshot(baseline.serverId, baseline.resource)).fingerprint, baseline.fingerprint, "Interrupted overlay baseline not restored")
checks.push({ phase: "interrupted-overlay-baseline-restored", marker: overlayCleanupMarker, restoredFingerprints: recoveryBaseline.length, newFixtureCreated: false }); await checkpoint()
} else if (wireguardInterfaceUpdate) await acceptChrWireguardInterfaceUpdate(marker, principal, hosts.map((_, index) => index + 1), execute, checks, checkpoint)
else if (wireguardInterfaceDelete) await acceptChrWireguardInterface(marker, principal, execute, checks, checkpoint)
else if (wireguardPeerOnly) await acceptChrWireguardPeer(marker, principal, execute, checks, checkpoint, wireguardPeerUpdate)
else if (ospfPassiveOnly) await acceptChrOspfPassive(marker, execute, checks, checkpoint)
else if (overlayAcceptance) await acceptChrOverlay(hosts, marker, principal, execute, checks, checkpoint)
else for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const resource = await client.get<Record<string, unknown>>("/system/resource")
const baseline = await readRosSnapshot(serverId, "bridge")
assert.ok(!baseline.rows.some(row => row.name === marker))
const routeBaseline = await Promise.all(["ipv4_route", "ipv6_route"].map(resource => readRosSnapshot(serverId, resource)))
const tableBaseline = await readRosSnapshot(serverId, "routing_table")
assert.ok(!tableBaseline.rows.some(row => row.name === marker))
const topology = await invokeMcpTool(principal, "mm_routing_topology_get", { serverId }) as { resources: Record<string, { observed: boolean; items: unknown[] }>; observedPath: boolean }
assert.equal(topology.observedPath, false)
assert.ok(topology.resources.interfaces.observed && topology.resources.ipv4.observed && topology.resources.ipv6.observed)
let pathProof: unknown
if (routingAcceptance && hosts.length === 2) {
const peerId = serverId === 1 ? 2 : 1, { client: peerClient } = await routerClient(peerId)
const addresses = await client.get<Record<string, string>[]>("/ip/address"), peerAddresses = await peerClient.get<Record<string, string>[]>("/ip/address")
const source = addresses.filter(row => row.interface === "ether1" && row.disabled !== "true" && row.address.split("/")[0] === host), peer = peerAddresses.filter(row => row.interface === "ether1" && row.disabled !== "true" && row.address.split("/")[0] === hosts[peerId - 1])
assert.equal(source.length, 1); assert.equal(peer.length, 1)
// Two physical devices validate the command/parser and direct control path.
// JumpHost/ExitNode collapse to the peer; this is explicitly not a 3-node failover lab.
const profile = { name: marker, serverId, community: "65000:65000", family: "ipv4", probeVrf: "main", candidates: [{ id: "direct-lab-path", jumpHostId: peerId, exitNodeId: peerId, wanInterface: "ether1", downstreamInterface: "ether1", gateway: peer[0].address.split("/")[0], gatewayRef: { resource: "ipv4_route", id: "not-used-by-probe" }, controlProbe: { targetInterface: "ether1", targetAddress: hosts[peerId - 1], sourceAddress: host, expectedHops: [{ serverId: peerId, interface: "ether1", address: hosts[peerId - 1] }] } }] }
pathProof = await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "direct-lab-path", proofPurpose: "control-path" })
assert.equal((pathProof as { status: string }).status, "passed", JSON.stringify(pathProof))
}
await assert.rejects(invokeMcpTool(principal, "mm_bridge_list", { serverId: 99999 }))
// Never touch an existing interface: an empty disabled bridge has no ports or addresses.
let cleanup = false
try {
cleanup = true // Includes ambiguous create outcomes: finally re-reads before attempting cleanup.
await execute("mm_bridge_create_plan", { serverId, fields: { name: marker, disabled: true, comment: marker } })
let own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row.name === marker && row.comment === marker)
assert.equal(own.length, 1); assert.equal(own[0].disabled, "true")
const id = own[0][".id"]
const updateOperationId = await execute("mm_bridge_update_plan", { serverId, id, fields: { comment: marker + ":verified" } })
own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row[".id"] === id)
assert.equal(own[0].comment, marker + ":verified")
await execute("mm_operation_recovery_plan", { operationId: updateOperationId })
own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row[".id"] === id)
assert.equal(own[0].comment, marker)
await execute("mm_bridge_delete_plan", { serverId, id })
cleanup = false
} finally {
if (cleanup) {
const own = (await readRosSnapshot(serverId, "bridge")).rows.filter(row => row.name === marker && [marker, marker + ":verified"].includes(String(row.comment)) && row.disabled === "true")
// Bounded cleanup uses the same approved executor; no broad name matching or direct DELETE.
for (const row of own) await execute("mm_bridge_delete_plan", { serverId, id: row[".id"] })
}
}
assert.equal((await readRosSnapshot(serverId, "bridge")).fingerprint, baseline.fingerprint, "Bridge configuration must return to its baseline")
if (routingAcceptance) {
// No rules or interfaces select this unique non-FIB table. Its routes never enter
// packet forwarding; existing main/default/management routes remain untouched.
try {
await execute("mm_routing_table_create_plan", { serverId, fields: { name: marker, fib: false } })
const tables = (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === marker)
assert.equal(tables.length, 1); assert.equal(tables[0].fib, "false")
await execute("mm_ipv4_route_create_plan", { serverId, fields: { "dst-address": "192.0.2.0/24", "routing-table": marker, gateway: "ether1", distance: 10, disabled: true, comment: marker } })
const own = (await readRosSnapshot(serverId, "ipv4_route")).rows.filter(row => row.comment === marker && row["routing-table"] === marker)
assert.equal(own.length, 1)
await assert.rejects(invokeMcpTool(principal, "mm_routing_table_delete_plan", { serverId, id: tables[0][".id"] }), /Таблица используется/)
const id = own[0][".id"]
const operationId = await execute("mm_ipv4_route_update_plan", { serverId, id, fields: { distance: 20, disabled: false } })
let current = (await readRosSnapshot(serverId, "ipv4_route")).rows.find(row => row[".id"] === id)!
assert.equal(current.distance, "20"); assert.equal(current.disabled, "false")
await execute("mm_operation_recovery_plan", { operationId })
current = (await readRosSnapshot(serverId, "ipv4_route")).rows.find(row => row[".id"] === id)!
assert.equal(current.distance, "10"); assert.equal(current.disabled, "true")
checks.push({ host, isolatedRouting: { tableFib: false, prefix: "192.0.2.0/24", approvedRouteCreateUpdate: true, enabledReadBack: true, usedTableDeletionRejected: true, approvedInverseRestoredDistanceAndDisabled: true, forwardingTest: false } })
} finally {
// Exact ownership and destination checks, including ambiguous create results.
const own = (await readRosSnapshot(serverId, "ipv4_route")).rows.filter(row => row.comment === marker && row["routing-table"] === marker && row["dst-address"] === "192.0.2.0/24")
for (const row of own) await execute("mm_ipv4_route_delete_plan", { serverId, id: row[".id"] })
assert.ok(!(await readRosSnapshot(serverId, "ipv4_route")).rows.some(row => row["routing-table"] === marker))
for (const row of (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === marker)) await execute("mm_routing_table_delete_plan", { serverId, id: row[".id"] })
}
assert.equal((await readRosSnapshot(serverId, "routing_table")).fingerprint, tableBaseline.fingerprint, "Routing table configuration must return to baseline")
}
for (const before of routeBaseline) assert.equal((await readRosSnapshot(serverId, before.resource)).fingerprint, before.fingerprint, "Existing route configuration changed during acceptance")
checks.push({ host, routerOs: resource.version, topology: Object.fromEntries(Object.entries(topology.resources).map(([key, value]) => [key, { observed: value.observed, count: value.items.length }])), approvedCreateUpdateDelete: true, approvedInversePlan: true, pathProof, twoDeviceControlPathOnly: routingAcceptance, idempotency: true, readBack: true, bridgeBaselineRestored: true, routeConfigurationUnchanged: true })
console.log(`✓ ${host}: actual topology, approval, create/update/delete, read-back, idempotency, cleanup`)
}
report.complete = true
} finally {
MikrotikClient.prototype.post = originalPost; MikrotikClient.prototype.patch = originalPatch
MikrotikClient.prototype.get = originalGet; MikrotikClient.prototype.delete = originalDelete
MikrotikClient.prototype.put = originalPut
report.finishedAt = new Date().toISOString()
await checkpoint()
await closePool()
}
@@ -0,0 +1,98 @@
import { fingerprint } from "./security.js"
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8)
const actor: AuthUser = { id: "chr-address-list-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, packetMatchingVerified: false, fixtures: "unused-address-list-v4-v6-permanent-and-timed", checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-address-list-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = ["firewall.addressLists.create", "firewall.addressLists.update", "firewall.addressLists.toggle", "firewall.addressLists.delete"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string }
await approvePlan(plan.planId, actor, true)
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
for (let attempt = 0; attempt < 200; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
assert.equal(row.state, "applied", tool + ": " + row.state + "/" + row.error_code)
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
return operation.id
}
await delay(300)
}
throw Error("Operation pending; never replay lab writes automatically")
}
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const resources = ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"]
const baseline = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
const paths = ["/ip/firewall/address-list", "/ipv6/firewall/address-list"]
const addressBaseline = await Promise.all(paths.map(path => client.get<Record<string, string>[]>(path)))
assert.ok(addressBaseline.every(rows => rows.length === 0), "Lab refuses existing address-list entries")
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })), addressFingerprints: addressBaseline.map(fingerprint) }); await save()
try {
for (const family of ["ip", "ip6"] as const) {
const path = family === "ip" ? paths[0] : paths[1]
const fields = { list: marker, address: family === "ip" ? "198.18.250.1/32" : "2001:db8:250::1/128", disabled: true, comment: marker }
const invoke = (operation: string, extra: Record<string, unknown>) => execute("mm_firewall_address_list_" + operation + "_plan", { serverId, address: { family, operation, ...extra } })
await invoke("create", { fields })
let rows = await client.get<Record<string, string>[]>(path)
assert.equal(rows.length, 1); const rosId = rows[0][".id"]
assert.equal(rows[0].disabled, "true"); assert.equal(rows[0].list, marker)
await invoke("update", { rosId, fields: { ...fields, address: family === "ip" ? "198.18.250.0-198.18.250.255" : "2001:db8:250:0:0:0:0:1/64", comment: marker + " edited" } })
await invoke("toggle", { rosId, disabled: false })
rows = await client.get<Record<string, string>[]>(path); assert.equal(rows[0].disabled, "false")
await invoke("toggle", { rosId, disabled: true }); await invoke("delete", { rosId })
assert.deepEqual(await client.get(path), [])
await invoke("create", { fields: { ...fields, disabled: false, timeout: "2m" } })
rows = await client.get<Record<string, string>[]>(path); assert.equal(rows.length, 1); assert.equal(rows[0].dynamic, "true")
const timedId = rows[0][".id"]
const timedPlan = await invokeMcpTool(principal, "mm_firewall_address_list_delete_plan", { serverId, address: { family, operation: "delete", rosId: timedId } }) as { planId: string }
await delay(1500); await approvePlan(timedPlan.planId, actor, true)
const timedDelete = await applyPlan(principal, timedPlan.planId, randomUUID())
for (let attempt = 0; attempt < 200; attempt++) {
const state = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [timedDelete.id])).rows[0].state
if (!["pending", "running"].includes(state)) { assert.equal(state, "applied"); break }
if (attempt === 199) throw Error("Timed delete pending; do not replay")
await delay(300)
}
assert.deepEqual(await client.get(path), [])
checks.push({ host, family, phase: "permanent-crud-and-countdown-delete", idempotencyVerified: true, unusedList: true, nativeTimedFields: { dynamic: rows[0].dynamic, timeout: rows[0].timeout } }); await save()
}
} finally {
for (const family of ["ip", "ip6"]) {
const path = family === "ip" ? paths[0] : paths[1]
for (const row of await client.get<Record<string, string>[]>(path)) if (row.list === marker && row.comment?.startsWith(marker)) await execute("mm_firewall_address_list_delete_plan", { serverId, address: { family, operation: "delete", rosId: row[".id"] } })
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Firewall baseline changed")
for (const [index, path] of paths.entries()) assert.equal(fingerprint(await client.get(path)), fingerprint(addressBaseline[index]), "Address-list baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log("✓ CHR " + serverId + ": IPv4/IPv6 address-list CRUD, native canonicalization, finite countdown deletion and cleanup")
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,186 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { invokeMcpTool } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { McpPrincipal } from "./store.js"
import { acceptChrBgpPrefixPath } from "./chr-bgp-forward-acceptance.js"
import { acceptChrBgpCommunity, cleanupChrCommunityPolicy, cleanupChrCommunityGatewayResolvers } from "./chr-bgp-community-acceptance.js"
type Execute = (tool: string, args: Record<string, unknown>) => Promise<string>
/** Recovery of an already terminated fixture, never creation or replay. */
export async function cleanupChrBgpFixture(marker: string, execute: Execute) {
assert.match(marker, /^mcp-test-[a-f0-9]{8}$/)
const instance = marker + "-bgp", vrf = marker + "-vrf", subnet = Number.parseInt(marker.slice(-2), 16), hex = Number.parseInt(marker.slice(-4), 16).toString(16)
for (const serverId of [1, 2]) {
for (const resource of ["bgp_connection", "bgp_instance"]) {
const own = (await readRosSnapshot(serverId, resource)).rows.filter(row => row.comment === marker)
assert.ok(own.length <= (resource === "bgp_connection" ? 2 : 1))
for (const row of own) {
assert.equal(row.vrf, vrf); assert.equal(row["routing-table"], vrf)
if (resource === "bgp_instance") { assert.equal(row.name, instance); assert.equal(String(row.as), serverId === 1 ? "64512" : "64513") }
else {
assert.ok([marker + "-peer", marker + "-peer-v6"].includes(String(row.name))); assert.equal(row.instance, instance)
const v6 = row.name === marker + "-peer-v6", width = v6 ? 128 : 32
const address = (id: number) => v6 ? `fd6d:6370:${hex}:1::${id}` : `198.18.${subnet}.${id}`
const expected = (value: unknown, id: number) => [address(id), address(id) + "/" + width, address(id) + "@" + vrf, address(id) + "/" + width + "@" + vrf].includes(String(value))
assert.ok(expected(row["remote.address"], serverId === 1 ? 2 : 1) && expected(row["local.address"], serverId), "Recovery refuses changed BGP endpoints")
}
await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
}
}
}
await cleanupChrCommunityPolicy(marker, execute)
for (const serverId of [1, 2]) {
const { client } = await routerClient(serverId)
const filters = await client.get<Record<string, string>[]>("/routing/filter/rule")
assert.ok(filters.every(row => row.chain === "bgp-in" && row.comment === "MikrotikManager: " + marker), "Recovery refuses foreign input filters")
if (filters.length) await execute("mm_filter_rules_replace_plan", { serverId, rules: [], requireUnbound: true })
}
}
export async function acceptChrBgp(marker: string, principal: McpPrincipal, execute: Execute, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
const instance = marker + "-bgp", connection = marker + "-peer", vrf = marker + "-vrf", subnet = Number.parseInt(marker.slice(-2), 16)
const addresses = [`198.18.${subnet}.1`, `198.18.${subnet}.2`]
const exportAcceptance = process.env.MMAPP_TEST_CHR_BGP_EXPORT === "true", hextet = Number.parseInt(marker.slice(-4), 16).toString(16)
const bindingAcceptance = process.env.MMAPP_TEST_CHR_BGP_FILTER_BINDING === "true"
const communityAcceptance = process.env.MMAPP_TEST_CHR_BGP_COMMUNITY === "true"
const peers = [{ name: connection, family: "ipv4", afi: exportAcceptance ? "ip" : "ip,ipv6", addresses },
...(exportAcceptance ? [{ name: connection + "-v6", family: "ipv6", afi: "ipv6", addresses: [`fd6d:6370:${hextet}:1::1`, `fd6d:6370:${hextet}:1::2`] }] : [])]
const baseline = []
for (const serverId of [1, 2]) for (const resource of ["bgp_instance", "bgp_connection", "ipv4_route", "ipv6_route", ...(bindingAcceptance ? ["routing_filter"] : [])]) baseline.push(await readRosSnapshot(serverId, resource))
if (bindingAcceptance) for (const row of baseline.filter(row => row.resource === "routing_filter")) assert.equal(row.rows.length, 0, "Binding fixture requires initially empty routing filters")
assert.ok(!baseline.flatMap(snapshot => snapshot.rows).some(row => row.name === instance || row.name === connection || row.comment === marker))
const observe = async (serverId: number, established: boolean, family?: string) => {
const { client } = await routerClient(serverId)
const selected = peers.filter(peer => !family || peer.family === family)
for (let attempt = 0; attempt < 30; attempt++) {
const sessions = await client.get<Record<string, string>[]>("/routing/bgp/session")
const matches = (row: Record<string, string>, peer: typeof peers[number]) => row.instance === instance && row.name?.startsWith(peer.name + "-") && row["remote.address"]?.split("@")[0].split("/")[0] === peer.addresses[serverId === 1 ? 1 : 0]
const own = sessions.filter(row => selected.some(peer => matches(row, peer)))
if (established ? own.length === selected.length && selected.every(peer => own.filter(row => matches(row, peer) && row.established === "true").length === 1) : own.every(row => row.established !== "true")) {
if (established) {
const parent = (await readRosSnapshot(serverId, "bgp_instance")).rows.find(row => row.name === instance && row.comment === marker)!
const bindings = (await readRosSnapshot(serverId, "bgp_connection")).rows
assert.equal(parent.vrf, vrf); assert.equal(parent["routing-table"], vrf)
for (const peer of selected) {
const row = own.find(row => matches(row, peer))!, binding = bindings.find(row => row.name === peer.name && row.comment === marker)!
checks.push({ serverId, phase: "bgp-session-observed", family: peer.family, session: Object.fromEntries(["name", "instance", "established", "vrf", "routing-table", "remote.address", "remote.as", "remote.afi", "local.address", "local.as", "local.afi"].map(key => [key, row[key] ?? null])) }); await checkpoint()
assert.equal(binding.instance, instance); assert.equal(binding.vrf, vrf); assert.equal(binding["routing-table"], vrf)
if (row.vrf !== undefined) assert.equal(row.vrf, vrf)
if (row["routing-table"] !== undefined) assert.equal(row["routing-table"], vrf)
assert.equal(Number(row["remote.as"]), serverId === 1 ? 64513 : 64512)
const families = row["remote.afi"]?.split(",") ?? []
assert.ok(peer.afi.split(",").every(afi => families.includes(afi)), JSON.stringify(row))
}
}
return own.map(row => ({ name: row.name, instance: row.instance, established: row.established ?? null, vrf: row.vrf ?? null, contextProof: "explicit-instance-and-connection", remoteAddress: row["remote.address"], remoteAs: row["remote.as"], remoteAfi: row["remote.afi"], prefixCount: row["prefix-count"] ?? null }))
}
if (attempt === 29) { checks.push({ serverId, bgpUnconfirmed: true, expectedEstablished: established, sessions: own }); await checkpoint(); assert.fail("BGP state was not confirmed in the isolated VRF") }
await delay(2000)
}
return []
}
const observePrefixes = async (serverId: number, present: boolean, families = ["ipv4", "ipv6"]) => {
const peerId = serverId === 1 ? 2 : 1
const expected = [{ resource: "/ip/route", family: "ipv4", prefix: `198.19.${subnet}.${peerId}/32` }, { resource: "/ipv6/route", family: "ipv6", prefix: `fd6d:6370:${hextet}:2::${peerId}/128` }].filter(target => families.includes(target.family))
for (let attempt = 0; attempt < 30; attempt++) {
const observed = await Promise.all(expected.map(async target => {
const view = await invokeMcpTool(principal, "mm_bgp_prefixes_get", { serverId, family: target.family, table: vrf, limit: 200 }) as { items: { prefix: string; table: string; active: boolean | null }[]; incomplete: boolean; forwardingVerified: boolean }
assert.equal(view.incomplete, false); assert.equal(view.forwardingVerified, false)
const own = view.items.filter(row => row.table === vrf && row.prefix === target.prefix)
assert.ok(own.length <= 1, "Ambiguous BGP fixture prefix")
return { family: target.family, prefix: target.prefix, table: vrf, present: own.length === 1, active: own[0]?.active ?? null, source: "mm_bgp_prefixes_get" }
}))
if (present ? observed.every(row => row.present && row.active) : observed.every(row => !row.present)) {
checks.push({ serverId, phase: present ? "bgp-prefixes-received" : "bgp-prefixes-withdrawn", observed, forwardingTest: false, communityTest: false }); await checkpoint()
if (process.env.MMAPP_TEST_CHR_BGP_FORWARD === "true") for (const target of expected) {
checks.push(await acceptChrBgpPrefixPath(marker, principal, serverId, target.family === "ipv4" ? "ipv4" : "ipv6", target.prefix.split("/")[0], present)); await checkpoint()
}
return
}
if (attempt === 29) { checks.push({ serverId, phase: "bgp-prefixes-unconfirmed", expectedPresent: present, observed }); await checkpoint(); assert.fail("BGP prefix exchange/withdrawal not confirmed") }
await delay(2000)
}
}
checks.push({ bgpPreflight: true, baselineFingerprints: baseline.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })), prefixExchangeTested: false, prefixExchangeRequested: exportAcceptance }); await checkpoint()
try {
for (const serverId of [1, 2]) {
await execute("mm_bgp_instance_create_plan", { serverId, fields: { name: instance, as: serverId === 1 ? 64512 : 64513, "router-id": addresses[serverId - 1], vrf, "routing-table": vrf, comment: marker } })
for (const peer of peers) await execute("mm_bgp_connection_create_plan", { serverId, fields: { name: peer.name, instance, "remote.address": peer.addresses[serverId === 1 ? 1 : 0], "local.address": peer.addresses[serverId - 1], "remote.as": serverId === 1 ? 64513 : 64512, "local.role": "ebgp", connect: true, listen: true, afi: peer.afi, vrf, "routing-table": vrf, comment: marker } })
const row = (await readRosSnapshot(serverId, "bgp_instance")).rows.find(row => row.name === instance)!
await assert.rejects(invokeMcpTool(principal, "mm_bgp_instance_delete_plan", { serverId, id: row[".id"] }), /используется подключением/)
}
for (const serverId of [1, 2]) { checks.push({ serverId, phase: "bgp-established", sessions: await observe(serverId, true) }); await checkpoint() }
if (exportAcceptance) {
const configureExport = async (serverId: number, mode: "connected" | "disabled") => {
for (const peer of peers) {
const row = (await readRosSnapshot(serverId, "bgp_connection")).rows.find(row => row.name === peer.name && row.comment === marker)!
const operationId = await execute("mm_bgp_export_plan", { serverId, id: row[".id"], mode })
checks.push({ serverId, phase: "bgp-export-configured", family: peer.family, mode, operationId, verificationScope: "configuration" }); await checkpoint()
}
}
for (const serverId of [1, 2]) await configureExport(serverId, "connected")
for (const serverId of [1, 2]) await observePrefixes(serverId, true)
if (bindingAcceptance && process.env.MMAPP_TEST_CHR_BGP_BINDING_FAULTS !== "false") {
for (const serverId of [1, 2]) await execute("mm_filter_rules_replace_plan", { serverId, rules: [{ community: "64512:999", action: "blackhole", gateway: "", gatewayTunnelId: "", description: "Owned unmatched Community; implicit reject is deliberate" }] })
const restart = async (serverId: number) => {
const row = (await readRosSnapshot(serverId, "bgp_connection")).rows.find(row => row.name === connection && row.comment === marker)!
await execute("mm_bgp_connection_update_plan", { serverId, id: row[".id"], fields: { disabled: true } })
await execute("mm_bgp_connection_update_plan", { serverId, id: row[".id"], fields: { disabled: false } })
await observe(1, true, "ipv4"); await observe(2, true, "ipv4")
}
for (const [serverId, direction] of [[1, "input"], [2, "output"]] as const) {
const row = (await readRosSnapshot(serverId, "bgp_connection")).rows.find(row => row.name === connection && row.comment === marker)!
await execute("mm_bgp_filter_binding_plan", { serverId, id: row[".id"], direction, chain: "bgp-in" })
await restart(serverId)
// Input rejects can remain in RIB as filtered/inactive. A missing row
// is not required; the separately verified packet path must fail.
const view = await invokeMcpTool(principal, "mm_bgp_prefixes_get", { serverId: 1, family: "ipv4", table: vrf, limit: 200 }) as { items: { prefix: string; active: boolean | null }[] }
checks.push({ phase: "bgp-binding-rejected-rib", serverId, direction, prefixes: view.items, communityTaggingVerified: false }); await checkpoint()
checks.push(await acceptChrBgpPrefixPath(marker, principal, 1, "ipv4", `198.19.${subnet}.2`, false)); await checkpoint()
await observePrefixes(1, true, ["ipv6"])
await execute("mm_bgp_filter_binding_plan", { serverId, id: row[".id"], direction, chain: "" })
await restart(serverId); await observePrefixes(1, true)
checks.push({ phase: "bgp-filter-binding-packet-accepted", serverId, direction, independentAfiPreserved: true, explicitApprovedPeerRestart: true, communityTaggingVerified: false }); await checkpoint()
}
}
if (bindingAcceptance && process.env.MMAPP_TEST_CHR_BGP_BINDING_FAULTS === "false") {
assert.equal(communityAcceptance, true)
checks.push({ phase: "binding-fault-suite-not-repeated", priorAcceptance: "mcp-chr-bgp-filter-binding-acceptance.json", reason: "Focused Community diagnostic; binding/peer restart/read-back still exercised by Community helper" }); await checkpoint()
}
if (communityAcceptance) await acceptChrBgpCommunity(marker, principal, execute, checks, checkpoint)
await configureExport(2, "disabled")
await observePrefixes(1, false)
await configureExport(2, "connected")
await observePrefixes(1, true)
checks.push({ prefixExchangeTested: true, family: ["ipv4", "ipv6"], explicitExportWithdrawalAndReannounce: true, communityTest: false, forwardingTest: false }); await checkpoint()
}
const peer = (await readRosSnapshot(2, "bgp_connection")).rows.find(row => row.name === connection && row.comment === marker)!
const operationId = await execute("mm_bgp_connection_update_plan", { serverId: 2, id: peer[".id"], fields: { disabled: true } })
checks.push({ phase: "bgp-administratively-disabled", sessions: await observe(1, false, exportAcceptance ? "ipv4" : undefined) }); await checkpoint()
if (exportAcceptance) {
await observePrefixes(1, false, ["ipv4"])
await observe(1, true, "ipv6"); await observePrefixes(1, true, ["ipv6"])
checks.push({ independentAddressFamilyStopVerified: true, independentPhysicalPathVerified: false }); await checkpoint()
}
await execute("mm_operation_recovery_plan", { operationId })
for (const serverId of [1, 2]) { checks.push({ serverId, phase: "bgp-recovered", sessions: await observe(serverId, true) }); await checkpoint() }
if (exportAcceptance) for (const serverId of [1, 2]) await observePrefixes(serverId, true)
} finally {
for (const serverId of [1, 2]) for (const resource of ["bgp_connection", "bgp_instance"]) {
const own = (await readRosSnapshot(serverId, resource)).rows.filter(row => row.comment === marker && (resource === "bgp_instance" ? row.name === instance : peers.some(peer => row.name === peer.name)))
for (const row of own) {
if (resource === "bgp_instance") assert.equal(row.vrf, vrf)
else assert.equal(row.instance, instance)
await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
}
}
for (const serverId of [1, 2]) await observe(serverId, false)
if (communityAcceptance) await cleanupChrCommunityPolicy(marker, execute)
if (bindingAcceptance) for (const serverId of [1, 2]) await execute("mm_filter_rules_replace_plan", { serverId, rules: [], requireUnbound: true })
if (exportAcceptance) for (const serverId of [1, 2]) await observePrefixes(serverId, false)
if (process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER === "true") await cleanupChrCommunityGatewayResolvers(marker, execute)
for (const snapshot of baseline) assert.equal((await readRosSnapshot(snapshot.serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "BGP configuration/routes baseline changed after cleanup")
checks.push({ bgpBaselineRestored: true, activeFixtureSessionsRemaining: 0, historicalSessionCacheCleared: false }); await checkpoint()
console.log("✓ BGP cleanup: owned configuration removed and baseline restored")
}
}
@@ -0,0 +1,178 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { invokeMcpTool } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { McpPrincipal } from "./store.js"
import { acceptChrBgpPrefixPath } from "./chr-bgp-forward-acceptance.js"
import { bgpCommunityPolicyChain, bgpCommunityPolicyComment } from "../../services/bgp-community-policy-model.js"
type Execute = (tool: string, args: Record<string, unknown>) => Promise<string>
async function diagnosticRoutes(serverId: number, prefix: string, table: string) {
const { client } = await routerClient(serverId)
const payload = await client.get<unknown>("/routing/route?dst-address=" + encodeURIComponent(prefix) + "&routing-table=" + encodeURIComponent(table))
const fields = new Set([".id", "afi", "contribution", "dst-address", "routing-table", "gateway", "immediate-gw", "active", "filtered", "unreachable", "blackhole", "distance", "scope", "target-scope", "bgp", "bgp.communities", "bgp-communities", "communities"])
assert.ok(Array.isArray(payload) && payload.length <= 20)
return payload.map(row => {
assert.ok(row && typeof row === "object")
return Object.fromEntries(Object.entries(row).filter(([key, value]) => fields.has(key) && ["string", "boolean"].includes(typeof value)).map(([key, value]) => [key, typeof value === "string" ? value.slice(0, 300) : value]))
})
}
export async function cleanupChrCommunityGatewayResolvers(marker: string, execute: Execute, recordedRoutes?: readonly Record<string, unknown>[]) {
assert.match(marker, /^mcp-test-[a-f0-9]{8}$/)
const subnet = Number.parseInt(marker.slice(-2), 16), hex = Number.parseInt(marker.slice(-4), 16).toString(16)
for (const family of ["ipv4", "ipv6"]) {
const resource = family + "_route", prefix = family === "ipv4" ? `198.18.${subnet}.2/32` : `fd6d:6370:${hex}:1::2/128`
const own = (await readRosSnapshot(1, resource)).rows.filter(row => row.comment === marker + "-gw-lookup")
assert.ok(own.length <= 1)
for (const row of own) {
assert.ok(row["dst-address"] === prefix && row["routing-table"] === "main", "Resolver cleanup refuses a changed destination")
if (row.gateway !== marker + "-gre@" + marker + "-vrf") {
// A removed VRF is printed as @*nativeID. Accept only an explicitly
// recorded route identity, unchanged config, an absent VRF object, and
// the original exact lab GRE. No other changed gateway is accepted.
const dangling = typeof row.gateway === "string" ? row.gateway.match(new RegExp("^" + marker + "-gre@(\\*[A-Za-z0-9]+)$"))?.[1] : undefined
const prior = recordedRoutes?.find(saved => saved[".id"] === row[".id"] && saved["dst-address"] === prefix && saved["routing-table"] === "main" && saved.gateway === marker + "-gre@" + marker + "-vrf")
assert.ok(dangling && prior && ["scope", "target-scope", "distance"].every(key => row[key] === prior[key]), "Resolver cleanup refuses an unrecorded or changed gateway")
const vrfs = (await readRosSnapshot(1, "vrf")).rows
assert.ok(!vrfs.some(vrf => vrf[".id"] === dangling || vrf.name === marker + "-vrf"), "Resolver cleanup refuses a live VRF reference")
const gre = (await readRosSnapshot(1, "gre")).rows.filter(iface => iface.name === marker + "-gre" && iface.comment === marker)
assert.ok(gre.length === 1 && gre[0]["local-address"] === "194.226.121.150" && gre[0]["remote-address"] === "104.165.244.7", "Resolver cleanup refuses a changed GRE")
console.log("✓ Recorded owned resolver: dangling deleted-VRF identity confirmed", { id: row[".id"], prefix, danglingVrfId: dangling })
}
}
for (const row of own) await execute(`mm_${resource}_delete_plan`, { serverId: 1, id: row[".id"] })
}
}
export const chrCommunityPolicyId = (marker: string) => marker + "-community"
export async function cleanupChrCommunityPolicy(marker: string, execute: Execute) {
const policyId = chrCommunityPolicyId(marker), { client } = await routerClient(2)
const rows = await client.get<Record<string, string>[]>("/routing/filter/rule")
const own = rows.filter(row => row.chain === bgpCommunityPolicyChain(policyId) || row.comment === bgpCommunityPolicyComment(policyId))
assert.ok(own.length <= 1 && own.every(row => row.chain === bgpCommunityPolicyChain(policyId) && row.comment === bgpCommunityPolicyComment(policyId)), "Cleanup refuses foreign/ambiguous output policy")
if (own.length) await execute("mm_bgp_community_policy_plan", { serverId: 2, policyId, rules: [], action: "delete" })
}
export async function acceptChrBgpCommunity(marker: string, principal: McpPrincipal, execute: Execute, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
const subnet = Number.parseInt(marker.slice(-2), 16), hex = Number.parseInt(marker.slice(-4), 16).toString(16), vrf = marker + "-vrf", policyId = chrCommunityPolicyId(marker)
const targets = { ipv4: `198.19.${subnet}.2`, ipv6: `fd6d:6370:${hex}:2::2` }, chain = bgpCommunityPolicyChain(policyId)
const peer = async (serverId: number, family: "ipv4" | "ipv6") => {
const rows = (await readRosSnapshot(serverId, "bgp_connection")).rows.filter(row => row.name === marker + "-peer" + (family === "ipv6" ? "-v6" : "") && row.comment === marker && row.vrf === vrf)
assert.equal(rows.length, 1); return String(rows[0][".id"])
}
const restart = async (family: "ipv4" | "ipv6") => {
const id = await peer(2, family)
await execute("mm_bgp_connection_update_plan", { serverId: 2, id, fields: { disabled: true } })
const prefix = targets[family] + (family === "ipv4" ? "/32" : "/128")
let withdrawn = false
for (let n = 0; n < 30; n++) {
const view = await invokeMcpTool(principal, "mm_bgp_prefixes_get", { serverId: 1, family, table: vrf, limit: 200 }) as { items: { id: string; prefix: string; active: boolean | null }[] }
const rows = view.items.filter(row => row.prefix === prefix)
if (!rows.length || rows.every(row => row.active === false)) { withdrawn = true; break }
await delay(2000)
}
assert.equal(withdrawn, true, "Old RIB must be absent/inactive before the fresh peer generation")
checks.push({ phase: "community-peer-down-prefix-fence", family, prefix, oldActiveRouteRemoved: true }); await checkpoint()
await execute("mm_bgp_connection_update_plan", { serverId: 2, id, fields: { disabled: false } })
}
const observe = async (family: "ipv4" | "ipv6", mode: "forward" | "missing" | "blackhole", phase: string) => {
const prefix = targets[family] + (family === "ipv4" ? "/32" : "/128")
let lastObserved: unknown = null, lastBlackholeObservation: unknown = null
for (let n = 0; n < 30; n++) {
const view = await invokeMcpTool(principal, "mm_bgp_prefixes_get", { serverId: 1, family, table: vrf, limit: 200 }) as { items: { id: string; prefix: string; active: boolean | null }[] }
const rows = view.items.filter(row => row.prefix === prefix); assert.ok(rows.length <= 1)
lastObserved = rows
if (mode === "missing" ? !rows.length || rows[0].active === false : rows.length === 1 && rows[0].active === true) {
if (mode === "blackhole") {
const nativeRoutes = await diagnosticRoutes(1, prefix, vrf)
lastBlackholeObservation = nativeRoutes
const route = nativeRoutes[0]
const communities = typeof route?.["bgp.communities"] === "string" ? String(route["bgp.communities"]).split(",").map(value => value.trim()) : []
const expectedTags = family === "ipv4" ? ["64512:998", "64512:999"] : ["64512:1000", "64512:1001"]
const bothTags = communities.length === 2 && expectedTags.every(tag => communities.includes(tag))
if (!bothTags || nativeRoutes.length !== 1 || route[".id"] !== rows[0].id || route["dst-address"] !== prefix || route["routing-table"] !== vrf || ![true, "true", "yes"].includes(route.blackhole as string | boolean) || ![true, "true", "yes"].includes(route.active as string | boolean)) {
await delay(2000); continue
}
checks.push({ phase: "community-blackhole-observed", family, prefix, nativeBlackhole: true, appendBothTagsDirectlyObserved: true, source: "/routing/route", nativeRoutes }); await checkpoint()
}
checks.push({ phase, family, mode, prefix, observedActive: rows[0]?.active ?? null, packetProof: await acceptChrBgpPrefixPath(marker, principal, 1, family, targets[family], mode === "forward"), freshApprovedPeerRestart: true }); await checkpoint(); return
}
await delay(2000)
}
// Preserve scoped read-only evidence before finally removes the fixture.
// /routing/route also lists filtered routes, unlike the selected FIB view.
const diagnostics: unknown[] = []
for (const serverId of [1, 2]) {
try {
const { client } = await routerClient(serverId)
diagnostics.push({ serverId, routes: await diagnosticRoutes(serverId, prefix, vrf) })
const sessions = await client.get<Record<string, unknown>[]>("/routing/bgp/session")
assert.ok(Array.isArray(sessions) && sessions.length <= 100)
diagnostics.push({ serverId, sessions: sessions.filter(row => typeof row.name === "string" && row.name.startsWith(marker + "-peer")).map(row => Object.fromEntries(["name", "established", "state", "uptime", "afi", "remote.address", "local.address"].flatMap(key => ["string", "boolean"].includes(typeof row[key]) ? [[key, String(row[key]).slice(0, 100)]] : []))) })
} catch { diagnostics.push({ serverId, diagnosticUnavailable: true }) }
}
checks.push({ phase: "community-route-unconfirmed-diagnostic", failedPhase: phase, family, mode, prefix, lastObserved, lastBlackholeObservation, diagnostics }); await checkpoint()
throw new Error("Community route state not confirmed: " + phase + "/" + family)
}
const literalGatewayDiagnostic = process.env.MMAPP_TEST_CHR_COMMUNITY_LITERAL_GATEWAY === "true"
const resolverDiagnostic = process.env.MMAPP_TEST_CHR_COMMUNITY_GATEWAY_RESOLVER === "true"
assert.ok(!resolverDiagnostic || !literalGatewayDiagnostic)
checks.push({ phase: "community-gateway-mode", mode: literalGatewayDiagnostic ? "literal-address-only-diagnostic" : "explicit-gre-interface", vrfIsolationPreserved: true }); await checkpoint()
if (resolverDiagnostic) {
const targetScope = process.env.MMAPP_TEST_CHR_COMMUNITY_RESOLVER_TARGET_SCOPE ?? "10"
assert.ok(["5", "10"].includes(targetScope))
for (const family of ["ipv4", "ipv6"]) {
const resource = family + "_route", prefix = family === "ipv4" ? `198.18.${subnet}.2/32` : `fd6d:6370:${hex}:1::2/128`
assert.ok(!(await readRosSnapshot(1, resource)).rows.some(row => row["routing-table"] === "main" && row["dst-address"] === prefix), "Resolver fixture refuses an existing exact main route")
await execute(`mm_${resource}_create_plan`, { serverId: 1, fields: { "dst-address": prefix, gateway: marker + "-gre@" + vrf, "routing-table": "main", scope: 10, "target-scope": Number(targetScope), comment: marker + "-gw-lookup" } })
const nativeRoutes = await diagnosticRoutes(1, prefix, "main")
checks.push({ phase: "community-gateway-resolver-created", serverId: 1, family, prefix, lookupTable: "main", destinationVrf: vrf, scope: 10, targetScope: Number(targetScope), nativeRoutes, ownedExactHostRoute: true, separateHumanPlan: true }); await checkpoint()
assert.ok(nativeRoutes.length === 1 && [true, "true"].includes(nativeRoutes[0].active as string | boolean) && typeof nativeRoutes[0]["immediate-gw"] === "string" && nativeRoutes[0]["immediate-gw"], "Resolver requires a positively active route and resolved gateway")
}
}
const inputRules = [
{ community: "64512:999", action: "route", gateway: `198.18.${subnet}.2`, gatewayTunnelId: literalGatewayDiagnostic ? "" : marker + "-gre", description: "Owned IPv4 Community match" },
{ community: "64512:1000", action: "route", gateway: `fd6d:6370:${hex}:1::2`, gatewayTunnelId: literalGatewayDiagnostic ? "" : marker + "-gre", description: "Owned IPv6 Community match" },
]
const outputRules = (v4 = "64512:999", v6 = "64512:1000") => [
{ prefix: targets.ipv4 + "/32", communityMode: "set", communities: [v4] }, { prefix: targets.ipv6 + "/128", communityMode: "set", communities: [v6] },
]
const output = (rules: unknown[]) => execute("mm_bgp_community_policy_plan", { serverId: 2, policyId, rules })
await execute("mm_filter_rules_replace_plan", { serverId: 1, rules: inputRules })
await output(outputRules())
for (const family of ["ipv4", "ipv6"] as const) {
await execute("mm_bgp_filter_binding_plan", { serverId: 2, id: await peer(2, family), direction: "output", chain })
await execute("mm_bgp_filter_binding_plan", { serverId: 1, id: await peer(1, family), direction: "input", chain: "bgp-in" })
}
const probe = await invokeMcpTool(principal, "mm_bgp_community_policy_plan", { serverId: 2, policyId, rules: outputRules() }) as { changes: { before: { nativeReferenceQueryMatches: number } } }
assert.ok(probe.changes.before.nativeReferenceQueryMatches >= 2, "Native OR reference query must find both bound AFI peers")
await assert.rejects(invokeMcpTool(principal, "mm_bgp_community_policy_plan", { serverId: 2, policyId, rules: [], action: "delete" }))
for (const family of ["ipv4", "ipv6"] as const) { await restart(family); await observe(family, "forward", "community-set-matched") }
for (const family of ["ipv4", "ipv6"] as const) {
await output(family === "ipv4" ? outputRules("64512:998") : outputRules(undefined, "64512:1001"))
await restart(family); await observe(family, "missing", "community-mismatch-rejected")
await observe(family === "ipv4" ? "ipv6" : "ipv4", "forward", "community-other-afi-preserved")
await output(outputRules()); await restart(family); await observe(family, "forward", "community-match-restored")
}
for (const family of ["ipv4", "ipv6"] as const) {
const otherFamily = family === "ipv4" ? "ipv6" : "ipv4", prefix = targets[family] + (family === "ipv4" ? "/32" : "/128")
const oldTag = family === "ipv4" ? "64512:998" : "64512:1001", newTag = family === "ipv4" ? "64512:999" : "64512:1000"
const otherRule = outputRules()[family === "ipv4" ? 1 : 0]
const pipeline = [
{ prefix, communityMode: "set", communities: [oldTag], action: "continue" },
{ prefix, communityMode: "append", communities: [newTag] }, otherRule,
]
await output(pipeline); await restart(family); await observe(family, "forward", "community-append-new-tag-matched")
await execute("mm_filter_rules_replace_plan", { serverId: 1, rules: [{ community: oldTag, action: "blackhole", gateway: "", gatewayTunnelId: "", description: "Prove append preserved previous tag: " + family }, ...inputRules] })
await restart(family); await observe(family, "blackhole", "community-append-old-tag-preserved")
await observe(otherFamily, "forward", "community-append-other-afi-preserved")
await output([{ ...pipeline[0] }, { ...pipeline[1], action: "continue" }, { prefix, communityMode: "clear" }, otherRule])
await restart(family); await observe(family, "missing", "community-clear-removed-both-tags")
await output(outputRules()); await restart(family); await observe(family, "forward", "community-after-clear-restored")
}
for (const family of ["ipv4", "ipv6"] as const) {
await execute("mm_bgp_filter_binding_plan", { serverId: 1, id: await peer(1, family), direction: "input", chain: "" })
await execute("mm_bgp_filter_binding_plan", { serverId: 2, id: await peer(2, family), direction: "output", chain: "" })
}
await cleanupChrCommunityPolicy(marker, execute)
checks.push({ phase: "community-export-policy-accepted", ipv4AndIpv6Match: true, afiMismatchIsolation: true, appendPreservedOldAndAddedNew: true, clearRemovedBoth: true, blackholeObserved: true,
appendClearBlackholeFamilies: ["ipv4", "ipv6"], attributesDirectlyObserved: true, propagationEvidence: "native append tag pairs plus fresh sessions, controlled Community predicates and packet/RIB changes", independentPhysicalPaths: false, endUserTrafficVerified: false }); await checkpoint()
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { invokeMcpTool } from "./runtime.js"
import { readRosSnapshot } from "./resources.js"
import type { McpPrincipal } from "./store.js"
/** Probe a remote owned loopback, whose only forward route is learned via BGP.
* The reply uses the connected GRE subnet; this is a two-node control probe. */
export async function acceptChrBgpPrefixPath(marker: string, principal: McpPrincipal, serverId: number, family: "ipv4" | "ipv6", target: string, present: boolean) {
const peerId = serverId === 1 ? 2 : 1, gre = marker + "-gre", loop = marker + "-bgp-loop", vrf = marker + "-vrf"
const subnet = Number.parseInt(marker.slice(-2), 16), hextet = Number.parseInt(marker.slice(-4), 16).toString(16)
const addresses = family === "ipv4" ? [`198.18.${subnet}.1`, `198.18.${subnet}.2`] : [`fd6d:6370:${hextet}:1::1`, `fd6d:6370:${hextet}:1::2`]
const source = (await readRosSnapshot(serverId, "gre")).rows.find(row => row.name === gre && row.comment === marker)
assert.ok(source)
const profile = { name: marker, serverId, community: "65000:65000", family, table: vrf, probeVrf: vrf, candidates: [{ id: "bgp-prefix", jumpHostId: peerId, exitNodeId: peerId, wanInterface: gre, downstreamInterface: gre,
gateway: addresses[peerId - 1], gatewayRef: { resource: "gre", id: source[".id"] }, controlProbe: { targetInterface: loop, targetAddress: target, sourceAddress: addresses[serverId - 1], expectedHops: [{ serverId: peerId, interface: loop, address: target }] } }] }
const proof = await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "bgp-prefix", proofPurpose: "control-path" }) as { status: string; packetCounts?: { sent: number; received: number } }
assert.equal(proof.status, present ? "passed" : "failed", JSON.stringify(proof))
assert.equal(proof.packetCounts?.sent, 3)
assert.equal(proof.packetCounts?.received, present ? 3 : 0)
return { serverId, family, phase: "bgp-prefix-path", expectedPresent: present, proof, proofScope: "two-node-BGP-forward-with-connected-GRE-return", independentPhysicalPaths: false, endUserTrafficVerified: false }
}
@@ -0,0 +1,67 @@
import assert from "node:assert/strict"
import { invokeMcpTool } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { McpPrincipal } from "./store.js"
type Execute = (tool: string, args: Record<string, unknown>) => Promise<string>
export async function acceptChrEcmp(marker: string, principal: McpPrincipal, execute: Execute, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
const gre = marker + "-gre", vrf = marker + "-vrf", bridge = marker + "-lo"
const subnet = Number.parseInt(marker.slice(-2), 16), hextet = Number.parseInt(marker.slice(-4), 16).toString(16)
const markerComment = "MikrotikManager: " + marker
const resources = ["bridge", "vrf", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route"]
const baseline = []
for (const serverId of [1, 2]) for (const resource of resources) baseline.push(await readRosSnapshot(serverId, resource))
assert.ok(!baseline.flatMap(snapshot => snapshot.rows).some(row => row.name === bridge || row.comment === markerComment))
const targets = { ipv4: `198.19.${subnet}.2`, ipv6: `fd6d:6370:${hextet}:2::2` }
const alt = { ipv4: [`198.18.${subnet}.5`, `198.18.${subnet}.6`], ipv6: [`fd6d:6370:${hextet}:3::1`, `fd6d:6370:${hextet}:3::2`] }
const vrfRow = (await readRosSnapshot(2, "vrf")).rows.find(row => row.name === vrf && row.comment === marker)!
const probe = async (family: "ipv4" | "ipv6", gatewayId: string) => {
const sourceAddress = family === "ipv4" ? `198.18.${subnet}.1` : `fd6d:6370:${hextet}:1::1`
const profile = { name: marker, serverId: 1, community: "65000:65000", family, table: vrf, probeVrf: vrf, candidates: [{ id: "ecmp-lab", jumpHostId: 2, exitNodeId: 2, wanInterface: gre, downstreamInterface: gre, gateway: targets[family], gatewayRef: { resource: family === "ipv4" ? "ipv4_route" : "ipv6_route", id: gatewayId }, controlProbe: { targetInterface: bridge, targetAddress: targets[family], sourceAddress, expectedHops: [{ serverId: 2, interface: bridge, address: targets[family] }] } }] }
return await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "ecmp-lab", proofPurpose: "control-path" }) as { status: string }
}
checks.push({ ecmpPreflight: true, baselineFingerprints: baseline.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })), independentPhysicalPaths: false }); await checkpoint()
try {
await execute("mm_bridge_create_plan", { serverId: 2, fields: { name: bridge, comment: markerComment, "protocol-mode": "none", disabled: false } })
await execute("mm_vrf_update_plan", { serverId: 2, id: vrfRow[".id"], fields: { interfaces: gre + "," + bridge } })
await execute("mm_ipv4_address_create_plan", { serverId: 2, fields: { interface: bridge, address: targets.ipv4 + "/32", comment: markerComment } })
await execute("mm_ipv6_address_create_plan", { serverId: 2, fields: { interface: bridge, address: targets.ipv6 + "/128", advertise: false, comment: markerComment } })
for (const serverId of [1, 2]) {
await execute("mm_ipv4_address_create_plan", { serverId, fields: { interface: gre, address: alt.ipv4[serverId - 1] + "/30", comment: markerComment } })
await execute("mm_ipv6_address_create_plan", { serverId, fields: { interface: gre, address: alt.ipv6[serverId - 1] + "/64", advertise: false, comment: markerComment } })
}
for (const family of ["ipv4", "ipv6"] as const) {
const resource = family === "ipv4" ? "ipv4_route" : "ipv6_route", prefix = targets[family] + (family === "ipv4" ? "/32" : "/128")
const gateways = [family === "ipv4" ? `198.18.${subnet}.2` : `fd6d:6370:${hextet}:1::2`, alt[family][1]]
for (const [index, gateway] of gateways.entries()) await execute(`mm_${resource}_create_plan`, { serverId: 1, fields: { "dst-address": prefix, "routing-table": vrf, gateway: gateway + "@" + vrf, distance: (index + 1) * 10, comment: markerComment } })
const { client } = await routerClient(1), path = family === "ipv4" ? "/ip/route" : "/ipv6/route"
const own = (await client.get<Record<string, string>[]>(path)).filter(row => row.comment === markerComment && row["dst-address"] === prefix && row["routing-table"] === vrf)
assert.equal(own.length, 2); assert.ok(own.some(row => row.active !== "true"), "Fixture must include a valid inactive higher-distance route")
assert.ok(own.every(row => !!row["immediate-gw"]))
const operationId = await execute("mm_ecmp_plan", { serverId: 1, family, routeIds: own.map(row => row[".id"]), distance: 10 })
const equal = (await client.get<Record<string, string>[]>(path)).filter(row => own.some(item => item[".id"] === row[".id"]))
assert.ok(equal.every(row => row.active === "true" && row.distance === "10"), JSON.stringify(equal))
const proof = await probe(family, own[0][".id"])
assert.equal(proof.status, "passed", JSON.stringify(proof))
await execute("mm_operation_recovery_plan", { operationId })
const restored = (await readRosSnapshot(1, resource)).rows.filter(row => own.some(item => item[".id"] === row[".id"]))
assert.deepEqual(restored.map(row => Number(row.distance)).sort((a, b) => a - b), [10, 20])
const inverseProof = await probe(family, own[0][".id"])
assert.equal(inverseProof.status, "passed", JSON.stringify(inverseProof))
checks.push({ family, phase: "ecmp", inactiveCandidateAccepted: true, equalActiveRoutes: true, proof, inverseDistanceRestored: true, inverseProof, independentPhysicalPaths: false }); await checkpoint()
console.log(`✓ ${family}: unequal distances → ECMP → approved inverse; routed loopback probes passed`)
for (const row of own) await execute(`mm_${resource}_delete_plan`, { serverId: 1, id: row[".id"] })
}
} finally {
for (const resource of ["ipv4_route", "ipv6_route"]) for (const row of (await readRosSnapshot(1, resource)).rows.filter(row => row.comment === markerComment && row["routing-table"] === vrf && [targets.ipv4 + "/32", targets.ipv6 + "/128"].includes(String(row["dst-address"])))) await execute(`mm_${resource}_delete_plan`, { serverId: 1, id: row[".id"] })
for (const serverId of [1, 2]) for (const resource of ["ipv4_address", "ipv6_address"]) for (const row of (await readRosSnapshot(serverId, resource)).rows.filter(row => row.comment === markerComment && [gre, bridge].includes(String(row.interface)))) await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
const current = (await readRosSnapshot(2, "vrf")).rows.find(row => row[".id"] === vrfRow[".id"])
if (current && current.interfaces !== gre) {
assert.equal(current.interfaces, gre + "," + bridge)
await execute("mm_vrf_update_plan", { serverId: 2, id: vrfRow[".id"], fields: { interfaces: gre } })
}
for (const row of (await readRosSnapshot(2, "bridge")).rows.filter(row => row.name === bridge && row.comment === markerComment)) await execute("mm_bridge_delete_plan", { serverId: 2, id: row[".id"] })
for (const snapshot of baseline) assert.equal((await readRosSnapshot(snapshot.serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "ECMP fixture baseline changed")
checks.push({ ecmpBaselineRestored: true }); await checkpoint()
}
}
@@ -0,0 +1,168 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { readFile, writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
import { MikrotikClient, MikrotikError } from "../../services/mikrotik.js"
import { compileBgpCommunityPolicy, bgpCommunityPolicyChain, bgpCommunityPolicyComment } from "../../services/bgp-community-policy-model.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const cleanupMarker = process.env.MMAPP_TEST_CHR_FILTER_CLEANUP_MARKER
const ipv6Acceptance = process.env.MMAPP_TEST_CHR_FILTER_IPV6 === "true"
const communityPolicyAcceptance = process.env.MMAPP_TEST_CHR_COMMUNITY_POLICY === "true"
const vrfAcceptance = process.env.MMAPP_TEST_CHR_FILTER_VRF === "true"
assert.ok(!vrfAcceptance || ipv6Acceptance && !cleanupMarker && !communityPolicyAcceptance)
if (cleanupMarker) assert.match(cleanupMarker, /^mcp-test-[a-f0-9]{8}$/)
const marker = cleanupMarker ?? "mcp-test-" + randomUUID().slice(0, 8), gre = marker + (cleanupMarker ? '-g } "$' : ipv6Acceptance ? "-g-test" : "-g test")
const actor: AuthUser = { id: "chr-filter-write-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin", "mm:filters:admin", "mm:bgp:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, fixtures: "require-unbound-routing-context-and-disabled-owned-GRE", communityPropagationVerified: false, forwardingVerified: false, checks, finishedAt: "" }
const save = () => writeFile(new URL(cleanupMarker ? "../../../../docs/mcp-chr-filter-write-recovery.json" : vrfAcceptance ? "../../../../docs/mcp-chr-filter-vrf-acceptance.json" : communityPolicyAcceptance ? "../../../../docs/mcp-chr-community-policy-acceptance.json" : ipv6Acceptance ? "../../../../docs/mcp-chr-filter-ipv6-acceptance.json" : "../../../../docs/mcp-chr-filter-write-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
const originalPost = MikrotikClient.prototype.post, originalPatch = MikrotikClient.prototype.patch
const captureParser = async (error: unknown, path: string) => {
if (!(error instanceof MikrotikError) || error.statusCode !== 400 || !path.startsWith("/routing/filter/rule")) return
let detail = ""
try { const body = JSON.parse(error.body); if (typeof body.detail === "string") detail = body.detail } catch { /* Never save response headers or raw payloads. */ }
detail = detail.replaceAll(process.env.MMAPP_TEST_CHR_PASSWORD!, "[redacted]").replace(/(?:password|secret|token|private-key)\s*[=:]\s*[^\s;,]+/gi, "[redacted]").slice(0, 500)
checks.push({ phase: "owned-native-parser-error", path, nativeStatus: 400, detail }); await save()
console.log({ ownedFilterParserDetail: detail })
}
MikrotikClient.prototype.post = async function<T>(path: string, body: Record<string, string | string[]>, timeoutMs?: number, signal?: AbortSignal): Promise<T> {
try { return await originalPost.call(this, path, body, timeoutMs, signal) as T } catch (error) { await captureParser(error, path); throw error }
}
MikrotikClient.prototype.patch = async function<T>(path: string, body: Record<string, string>, timeoutMs?: number,beforeDispatch?:()=>Promise<unknown>): Promise<T> {
try { return await originalPatch.call(this, path, body, timeoutMs,beforeDispatch) as T } catch (error) { await captureParser(error, path); throw error }
}
try {
await save(); await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:filters:write", "mm:network:read", "mm:network:write", "mm:bgp:read", ...(communityPolicyAcceptance ? ["mm:bgp:write"] : [])], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds: ["ros.gre.create", "ros.gre.delete", "filters.rules.replace", "filters.revision.restore", "routing.filters.catalog.read", ...(vrfAcceptance ? ["ros.vrf.create", "ros.vrf.delete"] : []), ...(communityPolicyAcceptance ? ["routing.bgp.community.policy"] : [])], allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string; state: string }
assert.equal(plan.state, "awaiting-approval"); await assert.rejects(applyPlan(principal, plan.planId, randomUUID()))
await approvePlan(plan.planId, actor, true)
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
for (let attempt = 0; attempt < 240; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
checks.push({ tool, operationId: operation.id, state: row.state, errorCode: row.error_code }); await save()
if (row.state !== "applied" && tool === "mm_bgp_community_policy_plan" && input.action !== "delete") {
const compiled = compileBgpCommunityPolicy({ policyId: input.policyId, rules: input.rules, defaultAction: input.defaultAction }), { client } = await routerClient(Number(input.serverId))
const rows = (await client.get<Record<string, string>[]>("/routing/filter/rule")).filter(value => value.chain === compiled.chain && value.comment === compiled.comment)
checks.push({ phase: "policy-failure-observed", serverId: input.serverId, nativeRows: rows.map(value => {
const actual = value.rule ?? "", firstDifference = Array.from({ length: Math.max(actual.length, compiled.body.length) }, (_, index) => index).find(index => actual[index] !== compiled.body[index]) ?? -1
return { id: value[".id"], disabled: value.disabled ?? null, invalid: value.invalid ?? null, expectedBytes: compiled.body.length, actualBytes: actual.length, firstDifference,
expectedCharacter: compiled.body.charCodeAt(firstDifference), actualCharacter: actual.charCodeAt(firstDifference), matchesWithoutHostMasks: actual === compiled.body.replace(/\/(?:32|128)(?=\))/g, "") }
}) }); await save()
}
if (row.state !== "applied" && tool.startsWith("mm_filter_")) {
const { client } = await routerClient(Number(input.serverId))
const filters = await client.get<Record<string, string>[]>("/routing/filter/rule")
checks.push({ phase: "filter-failure-observed", serverId: input.serverId, nativeRows: filters.map(value => ({ id: value[".id"], owned: value.comment === "MikrotikManager: " + marker, disabled: value.disabled ?? null, invalid: value.invalid ?? null, ruleBytes: value.rule?.length ?? null })),
nativeAudit: (await pool.query("SELECT action,payload->>'method' AS method,payload->>'path' AS path FROM mcp_audit WHERE connection_id=$1 AND action IN ('configuration.intent','configuration.acknowledged','configuration.unconfirmed') ORDER BY id DESC LIMIT 10", [access.connection.id])).rows }); await save()
}
assert.equal(row.state, "applied", tool + ": " + row.state + "/" + row.error_code)
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id); return operation.id
}
await delay(500)
}
throw Error("Operation pending; do not replay native writes")
}
const nativeWrites = async () => (await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE connection_id=$1 AND action='configuration.intent' AND payload->>'path' LIKE '/routing/filter/rule%'", [access.connection.id])).rows[0].n
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const resources = ["gre", "routing_filter", "bgp_instance", "bgp_connection", "ospf_instance", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route", "vrf", "routing_table"]
const baseline = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
if (cleanupMarker) {
const failed = JSON.parse(await readFile(new URL("../../../../docs/mcp-chr-filter-write-runtime-fence-error.json", import.meta.url), "utf8"))
assert.equal(failed.marker, cleanupMarker)
assert.deepEqual(await client.get("/routing/filter/rule"), [])
const owned = baseline.find(snapshot => snapshot.resource === "gre")!.rows.filter(row => row.name === gre && row.comment === marker)
for (const row of owned) { assert.equal(row.disabled, "true"); await execute("mm_gre_delete_plan", { serverId, id: row[".id"] }) }
const expected = failed.checks.find((row: { host?: string; phase?: string }) => row.host === host && row.phase === "preflight")
if (expected) for (const row of expected.baselineFingerprints) assert.equal((await readRosSnapshot(serverId, row.resource)).fingerprint, row.fingerprint)
else assert.equal(owned.length, 0)
checks.push({ host, phase: "cleanup", baselineRestored: !!expected, ownedRemoved: owned.length }); await save(); continue
}
for (const path of ["/routing/bgp/instance", "/routing/bgp/connection", "/routing/ospf/instance", "/routing/rip/instance", "/routing/isis/instance", "/routing/filter/select-rule", "/routing/filter/rule"]) assert.deepEqual(await client.get(path), [], "Native filter fixture refuses any existing routing contexts or filters")
assert.ok(!baseline.flatMap(snapshot => snapshot.rows).some(row => row.name === gre || row.comment === marker))
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })), routingContextsEmpty: true }); await save()
const blackhole = { community: "64512:11", communityName: "CHR fixture", action: "blackhole", gateway: "", gatewayTunnelId: "", description: "fixture" }
const route = { community: "64512:12", action: "route", gateway: "192.0.2.2", gatewayTunnelId: gre, description: "first }\nsecond" }
const route6 = { community: "64512:13", action: "route", gateway: "fd00:1::2", gatewayTunnelId: gre, description: "Owned IPv6 parser and metadata proof" }
const expandedRules = [blackhole, route, ...(ipv6Acceptance ? [route6] : [])]
const replace = (rules: unknown[], source = "apply") => execute("mm_filter_rules_replace_plan", { serverId, rules, source, requireUnbound: true })
const policyId = marker + "-community"
try {
if (communityPolicyAcceptance) {
const policy = (rules: unknown[], action = "replace") => execute("mm_bgp_community_policy_plan", { serverId, policyId, rules, action })
const rules = [{ prefix: "198.19.1.2/32", communityMode: "set", communities: ["64512:999"] }, { prefix: "fd6d:6370:1:2::2/128", communityMode: "set", communities: ["64512:1000"] }]
await policy(rules)
const beforeNoop = await nativeWrites(); await policy(rules); assert.equal(await nativeWrites(), beforeNoop)
await policy([{ prefix: "192.0.2.0/24", match: "subnet", minLength: 25, maxLength: 32, communityMode: "set", communities: ["64512:998"], action: "continue" },
{ prefix: "::/0", match: "subnet", minLength: 128, maxLength: 128, communityMode: "append", communities: ["64512:999"], action: "continue" },
{ prefix: "0.0.0.0/0", match: "subnet", communityMode: "clear" }])
await policy([], "delete")
// Production has the separate managed input chain beside this output chain.
// Prove their native ordering/preservation rather than testing an empty table only.
await replace([blackhole])
await policy(rules)
const withInputNoop = await nativeWrites(); await policy(rules); assert.equal(await nativeWrites(), withInputNoop)
await policy([], "delete")
checks.push({ host, phase: "community-policy-parser-noop-delete", exactDualStack: true, subnetRangeAndSingleLength: true, setAppendClear: true, propagationVerified: false }); await save(); continue
}
await assert.rejects(invokeMcpTool(principal, "mm_filter_rules_replace_plan", { serverId, rules: [{ ...route, gatewayTunnelId: gre + ' "$' }], requireUnbound: true }))
await execute("mm_gre_create_plan", { serverId, fields: { name: gre, "local-address": host, "remote-address": hosts[1 - index], disabled: true, comment: marker } })
if (vrfAcceptance) await execute("mm_vrf_create_plan", { serverId, fields: { name: marker + "-vrf", interfaces: gre, comment: marker } })
const globalGre = (await client.get<Record<string, string>[]>("/interface")).filter(row => row.name === gre)
const specificGre = (await client.get<Record<string, string>[]>("/interface/gre")).filter(row => row.name === gre)
checks.push({ host, phase: "interface-identity", globalIds: globalGre.map(row => row[".id"]), greIds: specificGre.map(row => row[".id"]) }); await save()
await replace([blackhole])
const revisionId = (await pool.query("SELECT id FROM config_revisions WHERE server_id=$1 AND section='filters' ORDER BY created_at DESC LIMIT 1", [serverId])).rows[0].id
await replace(expandedRules)
const beforeNoop = await nativeWrites(); await replace(expandedRules); assert.equal(await nativeWrites(), beforeNoop)
await replace([{ ...blackhole, communityName: "copied fixture" }, ...expandedRules.slice(1)], "copy"); assert.equal(await nativeWrites(), beforeNoop)
const catalog = await invokeMcpTool(principal, "mm_bgp_filter_rules_get", { serverId, limit: 200 }) as { rulesets: { live: boolean; stale: boolean; rules: { items: { community: string; gatewayTunnelId: string; description: string }[] } }[] }
assert.equal(catalog.rulesets[0].live, true); assert.equal(catalog.rulesets[0].stale, false)
const observed = catalog.rulesets[0].rules.items.find(row => row.community === route.community)!
assert.equal(observed.gatewayTunnelId, gre); assert.equal(observed.description, route.description)
await execute("mm_filter_revision_restore_plan", { serverId, revisionId, requireUnbound: true })
assert.equal((await pool.query("SELECT count(*)::int AS n FROM filter_rules WHERE server_id=$1", [serverId])).rows[0].n, 1)
checks.push({ host, phase: "create-replace-noop-copy-restore", nativeCompilerAccepted: true, quoteAndCommentRoundtrip: true, noForwardingClaim: true }); await save()
} finally {
if (communityPolicyAcceptance) {
const own = (await client.get<Record<string, string>[]>("/routing/filter/rule")).filter(row => row.chain === bgpCommunityPolicyChain(policyId) || row.comment === bgpCommunityPolicyComment(policyId))
assert.ok(own.length <= 1 && own.every(row => row.chain === bgpCommunityPolicyChain(policyId) && row.comment === bgpCommunityPolicyComment(policyId)))
if (own.length) await execute("mm_bgp_community_policy_plan", { serverId, policyId, rules: [], action: "delete" })
}
const raw = await client.get<Record<string, string>[]>("/routing/filter/rule")
assert.ok(raw.every(row => row.chain === "bgp-in" && row.comment === "MikrotikManager: " + marker), "Cleanup refuses foreign filters")
await replace([])
if (vrfAcceptance) {
const ownVrf = (await readRosSnapshot(serverId, "vrf")).rows.filter(row => row.name === marker + "-vrf" && row.comment === marker)
for (const row of ownVrf) { assert.equal(row.interfaces, gre); await execute("mm_vrf_delete_plan", { serverId, id: row[".id"] }) }
}
const ownGre = (await readRosSnapshot(serverId, "gre")).rows.filter(row => row.name === gre && row.comment === marker)
for (const row of ownGre) { assert.equal(row.disabled, "true"); await execute("mm_gre_delete_plan", { serverId, id: row[".id"] }) }
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Configuration baseline changed after filter cleanup")
for (const path of ["/routing/rip/instance", "/routing/isis/instance", "/routing/filter/select-rule"]) assert.deepEqual(await client.get(path), [])
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log("✓ CHR " + serverId + ": Community compiler, replace/no-op/copy/revision, read-back and cleanup")
}
report.complete = true
} finally { MikrotikClient.prototype.post = originalPost; MikrotikClient.prototype.patch = originalPatch; report.finishedAt = new Date().toISOString(); await save(); await closePool() }
@@ -0,0 +1,82 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8), comments = [1, 2, 3].map(id => `${marker} #${id}`)
const actor: AuthUser = { id: "chr-firewall-move-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, packetMatchingVerified: false, fixtures: "three-disabled-rules-in-unreferenced-chain", checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-firewall-move-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = ["ros.ip_filter.create", "ros.ip_filter.delete", "firewall.rules.move", "firewall.state.read"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string; changes: { before?: { rows: Record<string, string>[] } } }
if (tool === "mm_firewall_rule_move_plan") {
const rows = plan.changes.before?.rows
assert.equal(rows?.length, 3, "Never approve reordering an unexpected table")
assert.ok(rows!.every(row => row.chain === marker && comments.includes(row.comment) && row.disabled === "true"))
}
await approvePlan(plan.planId, actor, true)
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
for (let attempt = 0; attempt < 100; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
assert.equal(row.state, "applied", `${tool}: ${row.state}/${row.error_code}`)
return { operationId: operation.id, planId: plan.planId, key }
}
await delay(300)
}
throw Error("Operation pending; never automatically replay lab writes")
}
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const baseline = await Promise.all(["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"].map(resource => readRosSnapshot(serverId, resource)))
assert.equal(baseline[0].rows.length, 0, "Lab move refuses an existing filter table")
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })) }); await save()
try {
for (const comment of comments) await execute("mm_ip_filter_create_plan", { serverId, fields: { chain: marker, action: "accept", disabled: true, comment, "src-address": "192.0.2.0/24", "dst-address": "198.51.100.0/24" } })
const own = await client.get<Record<string, string>[]>("/ip/firewall/filter")
assert.equal(own.length, 3); assert.ok(own.every(row => row.chain === marker && comments.includes(row.comment) && row.disabled === "true"))
const ids = comments.map(comment => { const rows = own.filter(row => row.comment === comment); assert.equal(rows.length, 1); return rows[0][".id"] })
const moved = await execute("mm_firewall_rule_move_plan", { serverId, move: { family: "ip", table: "filter", rosId: ids[0], destinationRosId: ids[2] } })
assert.deepEqual((await client.get<Record<string, string>[]>("/ip/firewall/filter")).map(row => row[".id"]), [ids[1], ids[0], ids[2]])
assert.equal((await applyPlan(principal, moved.planId, moved.key)).id, moved.operationId)
const intents = await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE action='configuration.intent' AND payload->>'path'='/ip/firewall/filter/move' AND (payload->>'serverId')::int=$1", [serverId])
assert.equal(intents.rows[0].n, 1)
const reverted = await execute("mm_firewall_rule_move_plan", { serverId, move: { family: "ip", table: "filter", rosId: ids[0], destinationRosId: ids[1] } })
assert.deepEqual((await client.get<Record<string, string>[]>("/ip/firewall/filter")).map(row => row[".id"]), ids)
const state = await invokeMcpTool(principal, "mm_firewall_state_get", { serverId })
assert.ok(!JSON.stringify(state).includes(process.env.MMAPP_TEST_CHR_PASSWORD!))
checks.push({ host, phase: "move-and-explicit-return", operationId: moved.operationId, returnOperationId: reverted.operationId, idempotencyVerified: true, allRulesDisabled: true, sharedCatalog: state }); await save()
} finally {
for (const row of await client.get<Record<string, string>[]>("/ip/firewall/filter")) if (row.chain === marker && comments.includes(row.comment)) {
assert.equal(row.disabled, "true"); await execute("mm_ip_filter_delete_plan", { serverId, id: row[".id"] })
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Lab firewall baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log(`✓ CHR ${serverId}: disabled private-chain firewall move, exact return, no replay and complete baseline cleanup`)
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,97 @@
import { canonicalFirewallSnapshot } from "../../services/entity-snapshots.js"
import { fingerprintPayload } from "../../services/config-revisions.js"
import { fingerprint } from "./security.js"
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile, readFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8)
let previous: { marker?: string; complete?: boolean; startedAt?: string; checks?: { host?: string; phase?: string; baselineFingerprints?: { fingerprint: string }[]; addressFingerprints?: string[] }[] } | undefined
try { previous = JSON.parse(await readFile(new URL("../../../../docs/mcp-chr-firewall-restore-acceptance.json", import.meta.url), "utf8")) } catch { /* First run. */ }
const recoveryMarker = previous?.complete === false && /^mcp-test-[a-f0-9]{8}$/.test(previous.marker ?? "") && Date.now() - Date.parse(previous.startedAt ?? "") < 7200000 ? previous.marker : undefined
const actor: AuthUser = { id: "chr-firewall-restore-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, packetMatchingVerified: false, checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-firewall-restore-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds: ["firewall.revision.restore"], allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (serverId: number, revisionId: string) => {
const plan = await invokeMcpTool(principal, "mm_firewall_revision_restore_plan", { serverId, revisionId }) as { planId: string; changes: { before: { state: { chunks: { rows: Record<string, string>[] }[] } } } }
assert.ok(plan.changes.before.state.chunks.every(chunk => chunk.rows.every(row => row.disabled === "true" && [marker, recoveryMarker].some(prefix => prefix && row.comment?.startsWith(prefix)))), "Never approve unrelated/active lab rules")
await approvePlan(plan.planId, actor, true)
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
for (let attempt = 0; attempt < 300; attempt++) {
const row = (await pool.query("SELECT state,error_code,result FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
if (row.state !== "applied") { checks.push({ phase: "failed-operation", state: row.state, code: row.error_code, message: row.result, steps: (await pool.query("SELECT state,result FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows }); await save() }
assert.equal(row.state, "applied", row.state + "/" + row.error_code + "/" + row.result)
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
return operation.id
}
await delay(300)
}
throw Error("Restore pending; never replay lab writes automatically")
}
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
if (recoveryMarker) {
const knownEmpty = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945"
const proof = previous?.checks?.find(check => check.host === host && check.phase === "preflight")
if (proof?.baselineFingerprints?.length === 8 && proof.addressFingerprints?.length === 2 && proof.baselineFingerprints.every(row => row.fingerprint === knownEmpty) && proof.addressFingerprints.every(value => value === knownEmpty)) {
const rows = (await Promise.all(["ip", "ipv6"].flatMap(family => ["filter", "nat", "mangle", "raw", "address-list"].map(table => client.get<Record<string, string>[]>("/" + family + "/firewall/" + table))))).flat()
if (rows.length) {
assert.ok(rows.every(row => row.disabled === "true" && row.comment?.startsWith(recoveryMarker)), "Refuse recovery with foreign/active data")
const cleanupId = randomUUID(), empty = canonicalFirewallSnapshot({})
await pool.query("INSERT INTO config_revisions(id,server_id,section,source,fingerprint,payload) VALUES($1,$2,'firewall','apply',$3,$4::jsonb)", [cleanupId, serverId, fingerprintPayload(empty), JSON.stringify(empty)])
await execute(serverId, cleanupId)
checks.push({ host, phase: "previous-fixtures-reconciled", previousMarker: recoveryMarker }); await save()
}
}
}
const baseline = await Promise.all(["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"].map(resource => readRosSnapshot(serverId, resource)))
const paths = ["/ip/firewall/address-list", "/ipv6/firewall/address-list"], addressBaseline = await Promise.all(paths.map(path => client.get<Record<string, string>[]>(path)))
assert.ok(baseline.every(row => row.rows.length === 0) && addressBaseline.every(rows => rows.length === 0), "Lab restore refuses any existing Firewall configuration")
const payload = canonicalFirewallSnapshot({ rules: (["ip", "ip6"] as const).flatMap(family => ["accept", "accept", "drop"].map(action => ({ family, table: "filter" as const, chain: marker, action, comment: marker, disabled: true, native: { "connection-mark": "no-mark" } }))), addressLists: (["ip", "ip6"] as const).map(family => ({ family, list: marker, address: family === "ip" ? "198.18.249.0/24" : "2001:db8:249::/64", comment: marker, disabled: true })) })
const revisionId = randomUUID(), emptyId = randomUUID(), empty = canonicalFirewallSnapshot({})
for (const [id, value] of [[revisionId, payload], [emptyId, empty]] as const) await pool.query("INSERT INTO config_revisions(id,server_id,section,source,fingerprint,payload) VALUES($1,$2,'firewall','apply',$3,$4::jsonb)", [id, serverId, fingerprintPayload(value), JSON.stringify(value)])
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })), addressFingerprints: addressBaseline.map(fingerprint) }); await save()
try {
const operationId = await execute(serverId, revisionId)
for (const family of ["ip", "ipv6"]) {
const rows = await client.get<Record<string, string>[]>("/" + family + "/firewall/filter")
assert.deepEqual(rows.map(row => row.action), ["accept", "accept", "drop"]); assert.ok(rows.every(row => row.chain === marker && row.disabled === "true" && row["connection-mark"] === "no-mark"))
const lists = await client.get<Record<string, string>[]>("/" + family + "/firewall/address-list"); assert.equal(lists.length, 1); assert.equal(lists[0].list, marker)
}
const step = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1", [operationId])).rows[0].result
assert.equal(step.verified, true); assert.equal(step.completedCommands, 8); assert.equal(step.revisionRecorded, true)
checks.push({ host, phase: "restored", staticRules: 6, staticLists: 2, opaqueMatcherVerified: true, duplicateOrderVerified: true, idempotencyVerified: true }); await save()
} finally {
await execute(serverId, emptyId)
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Firewall baseline changed")
for (const [index, path] of paths.entries()) assert.equal(fingerprint(await client.get(path)), fingerprint(addressBaseline[index]), "Address-list baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log("✓ CHR " + serverId + ": native matcher/order/duplicates, staged static restore and explicit empty restore; baseline recovered")
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,84 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8)
const actor: AuthUser = { id: "chr-firewall-write-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, packetMatchingVerified: false, fixtures: "private-unreferenced-chain-v4-v6", checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-firewall-write-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = ["firewall.rules.create", "firewall.rules.update", "firewall.rules.toggle", "firewall.rules.delete", "ros.ip_filter.delete", "ros.ipv6_filter.delete"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string }
await approvePlan(plan.planId, actor, true)
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
for (let attempt = 0; attempt < 200; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
assert.equal(row.state, "applied", tool + ": " + row.state + "/" + row.error_code)
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
return operation.id
}
await delay(300)
}
throw Error("Operation pending; never replay lab writes automatically")
}
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const resources = ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"]
const baseline = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
assert.ok(baseline.filter(snapshot => ["ip_filter", "ipv6_filter"].includes(snapshot.resource)).every(snapshot => snapshot.rows.length === 0), "Lab refuses existing filter rules")
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })) }); await save()
try {
for (const family of ["ip", "ip6"] as const) {
const path = family === "ip" ? "/ip/firewall/filter" : "/ipv6/firewall/filter"
const fields = { chain: marker, action: "accept", disabled: true, comment: marker, srcAddress: family === "ip" ? "192.0.2.0/24" : "2001:db8::/64" }
const invoke = (operation: string, extra: Record<string, unknown>) => execute("mm_firewall_rule_form_" + operation + "_plan", { serverId, rule: { family, table: "filter", operation, ...extra } })
await invoke("create", { fields })
const rows = await client.get<Record<string, string>[]>(path)
assert.equal(rows.length, 1); const rosId = rows[0][".id"]
assert.equal(rows[0].disabled, "true"); assert.equal(rows[0].chain, marker)
await invoke("update", { rosId, fields: { ...fields, action: "drop", comment: marker + " edited" } })
const edited = await client.get<Record<string, string>[]>(path)
assert.equal(edited[0].action, "drop"); assert.equal(edited[0]["src-address"], fields.srcAddress)
await invoke("toggle", { rosId, disabled: false })
assert.equal((await client.get<Record<string, string>[]>(path))[0].disabled, "false")
await invoke("toggle", { rosId, disabled: true })
await invoke("delete", { rosId })
assert.deepEqual(await client.get(path), [])
checks.push({ host, family, phase: "create-edit-toggle-delete", idempotencyVerified: true, chainUnreferenced: true }); await save()
}
} finally {
for (const family of ["ip", "ip6"]) {
const path = family === "ip" ? "/ip/firewall/filter" : "/ipv6/firewall/filter", resource = family === "ip" ? "ip_filter" : "ipv6_filter"
for (const row of await client.get<Record<string, string>[]>(path)) if (row.chain === marker && row.comment.startsWith(marker)) await execute("mm_" + resource + "_delete_plan", { serverId, id: row[".id"] })
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Firewall baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log("✓ CHR " + serverId + ": IPv4/IPv6 firewall form CRUD, exact matchers, no replay and cleanup")
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,7 @@
import assert from "node:assert/strict"
export const chrOspfForwarding = () => process.env.MMAPP_TEST_CHR_OSPF_FORWARD === "true"
export function chrFixtureVrf(marker: string, serverId: number, forwarding = chrOspfForwarding()) {
assert.match(marker, /^mcp-test-[0-9a-f]{8}$/); assert.ok([1, 2].includes(serverId))
return marker + (forwarding ? `-vrf-${serverId}` : "-vrf")
}
@@ -0,0 +1,118 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS??"").split(",").filter(Boolean), ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
assert.equal(hosts.length,2);assert.equal(new Set(hosts).size,2);assert.ok(hosts.every(host=>["194.226.121.150","104.165.244.7"].includes(host)))
if(ports)assert.ok(ports.length===2&&ports.every(port=>Number.isInteger(port)&&port>=1024&&port<=65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER&&process.env.MMAPP_TEST_CHR_PASSWORD)
const marker="mcp-gre-"+randomUUID().slice(0,8), actor:AuthUser={id:"chr-gre-service-owner",apps:["mm"],permissions:["mm:settings:admin","mm:network:admin","mm:servers:admin"],expiresAt:Date.now()+3600000}
const checks:Record<string,unknown>[]=[]
const report={startedAt:new Date().toISOString(),marker,complete:false,forwardingVerified:false,ipsecSaVerified:false,fixtures:"disabled owned GRE; static v4 then v6",checks,finishedAt:""}
const save=()=>writeFile(new URL("../../../../docs/mcp-chr-gre-mutation-acceptance.json",import.meta.url),JSON.stringify(report,null,2)+"\n")
try {
await applySqlMigrations(pool);assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0)
await updateMcpSettings({enabled:true},actor)
for(const [index,host]of hosts.entries())await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)",[index+1,marker,ports?"127.0.0.1":host,ports?.[index]??80,process.env.MMAPP_TEST_CHR_USER,process.env.MMAPP_TEST_CHR_PASSWORD])
const access=await createMcpConnection(mcpConnectionCreateSchema.parse({name:marker,mode:"approval",expiresAt:new Date(Date.now()+1800000).toISOString(),permissions:["mm:network:write"],limits:{callsPerMinute:120,mutationsPerHour:300},scope:{serverIds:[1,2],operationIds:["network.gre.create","network.gre.update","network.gre.delete","ros.gre.delete","ros.gre.update","ros.ipv4_address.delete","ros.ipv6_address.delete"],allowGlobal:false}}),actor)
const principal=await authenticateMcpToken(access.token)
const execute=async(tool:string,input:Record<string,unknown>)=>{
const plan=await invokeMcpTool(principal,tool,input) as {planId:string};await approvePlan(plan.planId,actor,true)
const key=randomUUID(),operation=await applyPlan(principal,plan.planId,key)
for(let attempt=0;attempt<200;attempt++){
const row=(await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1",[operation.id])).rows[0]
if(!["pending","running"].includes(row.state)){
const steps=(await pool.query("SELECT state,result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index",[operation.id])).rows
checks.push({tool,serverId:input.serverId,operationId:operation.id,state:row.state,errorCode:row.error_code,steps});await save()
assert.equal(row.state,"applied",tool+": "+row.state+"/"+row.error_code)
assert.equal((await applyPlan(principal,plan.planId,key)).id,operation.id)
return
}
await delay(300)
}
throw Error("GRE operation is still running; do not replay lab writes")
}
await save()
for(const[index,host]of hosts.entries()){
const serverId=index+1,{client}=await routerClient(serverId),name=marker+"-"+serverId,nativeName=marker+"-native-"+serverId,renamedName=marker+"-renamed-"+serverId
const ownedIds=new Set<string>()
const ipv4=`198.18.253.${index*4+1}/30`,ipv6=`fd00:ab:45:${serverId}::1/64`
const resources=["gre","ipv4_address","ipv6_address","vrf","routing_table","ipv4_route","ipv6_route","routing_filter"]
const baseline=await Promise.all(resources.map(resource=>readRosSnapshot(serverId,resource)))
const nativeGre=await client.get<Record<string,string>[]>("/interface/gre")
assert.ok(!nativeGre.some(row=>row.name===name))
for(const path of ["/ip/address","/ipv6/address"])assert.ok(!(await client.get<Record<string,string>[]>(path)).some(row=>row.address===ipv4||row.address===ipv6),"Fixture address already exists")
try {
await execute("mm_gre_tunnel_create_plan",{serverId,clearIpsecSecret:true,fields:{name,remoteAddress:"198.51.100.254",localInnerIp:ipv4,enabled:false,comment:marker,mtu:1400,keepaliveInterval:10,keepaliveRetries:10}})
let gre=(await client.get<Record<string,string>[]>("/interface/gre")).find(row=>row.name===name)!
assert.ok(gre);assert.equal(gre.disabled,"true");assert.equal(gre.mtu,"1400")
const rosId=gre[".id"]
ownedIds.add(rosId)
assert.ok((await client.get<Record<string,string>[]>("/ip/address")).some(row=>row.interface===name&&row.address===ipv4))
await execute("mm_gre_tunnel_update_plan",{serverId,rosId,fields:{localInnerIp:ipv6,mtu:1390,comment:marker+" edited",enabled:false}})
gre=(await client.get<Record<string,string>[]>("/interface/gre")).find(row=>row[".id"]===rosId)!
assert.equal(gre.disabled,"true");assert.equal(gre.mtu,"1390")
assert.ok(!(await client.get<Record<string,string>[]>("/ip/address")).some(row=>row.interface===name))
const address=(await client.get<Record<string,string>[]>("/ipv6/address")).find(row=>row.interface===name&&row.address===ipv6)!
assert.ok(address);assert.equal(address.advertise,"false")
await execute("mm_gre_tunnel_delete_plan",{serverId,rosId})
assert.ok(!(await client.get<Record<string,string>[]>("/interface/gre")).some(row=>row.name===name))
checks.push({host,phase:"disabled-v4-v6-crud",nativeId:rosId,ipv6Advertise:address.advertise,explicitEmptyPskConfigurationVerified:true,idempotencyVerified:true,forwardingVerified:false});await save()
await execute("mm_gre_tunnel_create_plan",{serverId,fields:{name:nativeName,remoteAddress:"198.51.100.254",enabled:false,comment:marker,mtu:1400}})
const native=(await client.get<Record<string,string>[]>("/interface/gre")).find(row=>row.name===nativeName)!
assert.ok(native);assert.equal(native.disabled,"true")
ownedIds.add(native[".id"])
await execute("mm_gre_update_plan",{serverId,id:native[".id"],fields:{name:renamedName,comment:""}})
const renamed=(await client.get<Record<string,string>[]>("/interface/gre")).find(row=>row[".id"]===native[".id"])!
assert.equal(renamed.name,renamedName);assert.equal(renamed.disabled,"true");assert.equal(renamed.mtu,"1400");assert.equal(renamed.comment??"","")
await execute("mm_gre_delete_plan",{serverId,id:native[".id"]})
checks.push({host,phase:"native-alias-rename-delete",nativeId:native[".id"],oneInterfaceOnly:true,emptyCommentVerified:true,idempotencyVerified:true,forwardingVerified:false});await save()
} catch(error) {
const own=(await client.get<Record<string,string>[]>("/interface/gre")).find(row=>row.name===name&&row.comment?.startsWith(marker))
if(own)for(const[label,body]of [
["get-id",{".id":own[".id"],"value-name":"ipsec-secret"}],
["get-number",{number:own[".id"],"value-name":"ipsec-secret"}],
["get-numbers",{numbers:own[".id"],"value-name":"ipsec-secret"}],
["print-id",{"show-sensitive":"",".proplist":".id,ipsec-secret",".query":[".id="+own[".id"]]}],
["print-name",{"show-sensitive":"",".proplist":".id,ipsec-secret",".query":["name="+name]}],
["print-default",{".proplist":".id,ipsec-secret",".query":["name="+name]}],
] as const){
try{
const raw=await client.post<unknown>("/interface/gre/"+(label.startsWith("get")?"get":"print"),body as Record<string,string|string[]>)
const items=Array.isArray(raw)?raw:[raw]
checks.push({host,phase:"psk-source-diagnostic",method:label,records:items.length,shape:items.map(row=>({retType:typeof row?.ret,retEmpty:row?.ret==="",secretType:typeof row?.["ipsec-secret"],secretEmpty:row?.["ipsec-secret"]==="",identityPresent:typeof row?.[".id"]==="string"}))})
}catch(probe){checks.push({host,phase:"psk-source-diagnostic",method:label,httpStatus:probe&&typeof probe==="object"&&"statusCode"in probe?probe.statusCode:null})}
}
await save()
try {
const {readGreMutationState}=await import("../../services/gre-mutation-state.js")
const {server}=await routerClient(serverId)
await readGreMutationState(server,{operation:"delete",name})
} catch(diagnostic) {
const message=diagnostic instanceof Error?diagnostic.message:""
checks.push({host,phase:"failure-diagnosis",sourceError:message.startsWith("Не подтверждено")||message.startsWith("Неполный")?message:"Unexpected read-back failure"});await save()
}
throw error
} finally {
// A failed service operation is never replayed. Cleanup is a separate,
// freshly approved native plan limited to this run's exact fixture name.
for(const gre of await client.get<Record<string,string>[]>("/interface/gre"))if([name,nativeName,renamedName].includes(gre.name)&&(gre.comment?.startsWith(marker)||ownedIds.has(gre[".id"]))){
if(gre.disabled!=="true")await execute("mm_gre_update_plan",{serverId,id:gre[".id"],fields:{disabled:true}})
for(const[resource,path]of [["ipv4_address","/ip/address"],["ipv6_address","/ipv6/address"]])for(const row of await client.get<Record<string,string>[]>(path))if(row.interface===gre.name&&[ipv4,ipv6].includes(row.address)&&row.dynamic!=="true")await execute(`mm_${resource}_delete_plan`,{serverId,id:row[".id"]})
await execute("mm_gre_delete_plan",{serverId,id:gre[".id"]})
}
for(const snapshot of baseline)assert.equal((await readRosSnapshot(serverId,snapshot.resource)).fingerprint,snapshot.fingerprint,"GRE lab baseline differs: "+snapshot.resource)
checks.push({host,phase:"cleanup",baselineRestored:true,fingerprints:baseline.map(row=>({resource:row.resource,fingerprint:row.fingerprint}))});await save()
}
console.log(`✓ CHR ${serverId}: service GRE CRUD, disabled v4→v6/empty PSK, native rename/delete, idempotency and baseline restored`)
}
report.complete=true;report.finishedAt=new Date().toISOString();await save()
} finally {await closePool()}
@@ -0,0 +1,67 @@
import assert from "node:assert/strict"
import {randomUUID} from "node:crypto"
import {readFile,writeFile} from "node:fs/promises"
import {setTimeout as delay} from "node:timers/promises"
import {pool,closePool} from "../../db/index.js"
import {applySqlMigrations} from "../../db/migrate.js"
import {MikrotikClient} from "../../services/mikrotik.js"
import {mcpConnectionCreateSchema} from "@mmapp/contracts/mcp"
import {createMcpConnection,authenticateMcpToken,updateMcpSettings} from "./store.js"
import {invokeMcpTool,approvePlan,applyPlan} from "./runtime.js"
import {readRosSnapshot,routerClient} from "./resources.js"
import {chrNativeFailure} from "./chr-native-failure.js"
import type {AuthUser} from "../../lib/permissions.js"
// This is a separate new human cleanup, never a replay of an unknown operation.
const prior=JSON.parse(await readFile(new URL("../../../../docs/mcp-chr-gre-revision-f1147896-auth-interrupted.json",import.meta.url),"utf8"))
assert.equal(prior.marker,"mcp-test-f1147896");assert.equal(prior.complete,false);assert.ok(prior.finishedAt)
assert.ok(prior.checks.some((row:Record<string,unknown>)=>row.phase==="mcp-operation"&&row.state==="unknown"))
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??"").split(","),ports=process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
assert.deepEqual(hosts,["194.226.121.150","104.165.244.7"]);assert.equal(process.env.MMAPP_TEST_CHR_USER,"admin");assert.ok(process.env.MMAPP_TEST_CHR_PASSWORD)
if(ports)assert.ok(ports.length===2&&ports.every(port=>Number.isInteger(port)&&port>=1024&&port<=65535))
const marker=prior.marker as string,checks:Record<string,unknown>[]=[],dispatches:Record<string,unknown>[]=[]
const report={startedAt:new Date().toISOString(),marker,complete:false,originalAttemptAccepted:false,unknownOperationReplayed:false,checks,dispatches,
originalPrivateProofPortable:false,privateProofLimitation:"The original disposable MCP signing key and SQL source rows were removed by the finished harness. Recovery can compare all 16 original native fingerprints; it cannot recreate server 2 original private dependency HMAC."}
const save=()=>writeFile(new URL("../../../../docs/mcp-chr-gre-revision-f1147896-recovery.json",import.meta.url),JSON.stringify(report,null,2)+"\n")
const get=MikrotikClient.prototype.get,post=MikrotikClient.prototype.post,del=MikrotikClient.prototype.delete
let pace=Promise.resolve(),last=0
const gap=()=>{const work=pace.then(async()=>{const wait=300-(Date.now()-last);if(wait>0)await delay(wait);last=Date.now()});pace=work.catch(()=>{});return work}
MikrotikClient.prototype.get=async function<T>(path:string,timeout?:number,signal?:AbortSignal){await gap();try{return await get.call(this,path,timeout,signal)as T}catch(error){checks.push(chrNativeFailure(error,"GET",path,Date.now()));await save();throw error}}
MikrotikClient.prototype.post=async function<T>(path:string,body:Record<string,string|string[]>,timeout?:number,signal?:AbortSignal){assert.ok(path.endsWith("/get")||path.endsWith("/print"));await gap();return await post.call(this,path,body,timeout,signal)as T}
MikrotikClient.prototype.delete=async function(path:string,timeout?:number,before?:()=>Promise<unknown>){assert.match(path,/^\/(?:interface\/gre|ip\/address|ipv6\/address)\/\*[A-Za-z0-9]+$/);await gap();return del.call(this,path,timeout,async()=>{await before?.();dispatches.push({method:"DELETE",path,at:new Date().toISOString()});await save()})}
const actor:AuthUser={id:"chr-gre-revision-recovery",apps:["mm"],permissions:["mm:settings:admin","mm:network:admin"],expiresAt:Date.now()+7200000}
try{
await save();await applySqlMigrations(pool);assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0)
await updateMcpSettings({enabled:true},actor)
for(const[index,host]of hosts.entries())await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)",[index+1,marker,ports?"127.0.0.1":host,ports?.[index]??80,"admin",process.env.MMAPP_TEST_CHR_PASSWORD])
const issued=await createMcpConnection(mcpConnectionCreateSchema.parse({name:marker+" recovery",mode:"approval",expiresAt:new Date(Date.now()+7200000).toISOString(),permissions:["mm:network:read","mm:network:write"],scope:{serverIds:[1,2],operationIds:["ros.gre.delete","ros.ipv4_address.delete","ros.ipv6_address.delete"],allowGlobal:false},limits:{callsPerMinute:1000,mutationsPerHour:100}}),actor),principal=await authenticateMcpToken(issued.token)
const execute=async(tool:string,input:Record<string,unknown>)=>{
const plan=await invokeMcpTool(principal,tool,input)as{planId:string;state:string};assert.equal(plan.state,"awaiting-approval");await approvePlan(plan.planId,actor,true)
const operation=await applyPlan(principal,plan.planId,randomUUID());checks.push({phase:"fresh-cleanup-plan",tool,serverId:input.serverId,planId:plan.planId,operationId:operation.id});await save()
for(let i=0;i<480;i++){const row=(await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1",[operation.id])).rows[0];if(!["pending","running"].includes(row.state)){checks.push({phase:"cleanup-terminal",operationId:operation.id,state:row.state,errorCode:row.error_code});await save();assert.equal(row.state,"applied");return}await delay(500)}
throw Error("Live cleanup operation; no new commands or retry allowed")
}
// Confirm both endpoints and known ownership before the first cleanup command.
for(const serverId of [1,2]){const {client}=await routerClient(serverId);await client.get("/system/resource");const rows=await client.get<Record<string,string>[]>("/interface/gre");assert.ok(rows.every(row=>[marker+"-a",marker+"-b"].includes(row.name)&&row.comment===marker&&row.disabled==="true"));checks.push({phase:"preflight",serverId,ownedGre:rows.length});await save()}
for(const serverId of [1,2]){
const {client}=await routerClient(serverId)
const allowed=new Set([`198.18.251.${serverId}/30`,`198.18.252.${serverId}/30`,`fd00:ab:46:${serverId}::1/64`])
for(const gre of await client.get<Record<string,string>[]>("/interface/gre")){
assert.ok([marker+"-a",marker+"-b"].includes(gre.name)&&gre.comment===marker&&gre.disabled==="true")
assert.equal(gre["remote-address"],gre.name===marker+"-a"?"198.51.100.253":"198.51.100.254")
for(const [resource,path]of [["ipv4_address","/ip/address"],["ipv6_address","/ipv6/address"]])for(const address of await client.get<Record<string,string>[]>(path))if(address.interface===gre.name){
// Kernel link-local rows are not independently configurable and leave
// with the positively owned parent. Other dynamic addresses refuse cleanup.
if(path==="/ipv6/address"&&address.dynamic==="true"&&/^fe80:/i.test(address.address))continue
assert.equal(address.dynamic,"false");assert.ok(allowed.has(address.address));await execute(`mm_${resource}_delete_plan`,{serverId,id:address[".id"]})
}
await execute("mm_gre_delete_plan",{serverId,id:gre[".id"]})
}
const baselines=prior.checks.find((row:Record<string,unknown>)=>row.phase==="preflight").baselineFingerprints.filter((row:{serverId:number})=>row.serverId===serverId)
assert.equal(baselines.length,8)
for(const baseline of baselines)assert.equal((await readRosSnapshot(serverId,baseline.resource)).fingerprint,baseline.fingerprint,`Original native baseline ${serverId}:${baseline.resource}`)
checks.push({phase:"original-native-baseline-restored",serverId,fingerprints:8,ownedObjectsRemoved:true});await save()
}
report.complete=true
}catch(error){checks.push({phase:"failure",name:error instanceof Error?error.name:"unknown"});process.exitCode=1}
finally{Object.assign(report,{finishedAt:new Date().toISOString()});await save();MikrotikClient.prototype.get=get;MikrotikClient.prototype.post=post;MikrotikClient.prototype.delete=del;await closePool()}
@@ -0,0 +1,180 @@
import assert from "node:assert/strict"
import {randomUUID} from "node:crypto"
import {writeFile} from "node:fs/promises"
import {setTimeout as delay} from "node:timers/promises"
import {pool,closePool} from "../../db/index.js"
import {applySqlMigrations} from "../../db/migrate.js"
import {canonicalGreSnapshot} from "../../services/entity-snapshots.js"
import {fingerprintPayload} from "../../services/config-revisions.js"
import {readGreRestoreState,greRestoreStateProof} from "../../services/gre-revision-restore.js"
import {MikrotikClient,MikrotikError} from "../../services/mikrotik.js"
import {mcpConnectionCreateSchema} from "@mmapp/contracts/mcp"
import {createMcpConnection,authenticateMcpToken,updateMcpSettings} from "./store.js"
import {invokeMcpTool,approvePlan,applyPlan} from "./runtime.js"
import {readRosSnapshot,routerClient,type RosSnapshot} from "./resources.js"
import {chrNativeFailure} from "./chr-native-failure.js"
import type {AuthUser} from "../../lib/permissions.js"
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??"").split(",")
assert.deepEqual(hosts,["194.226.121.150","104.165.244.7"])
assert.equal(process.env.MMAPP_TEST_CHR_USER,"admin");assert.ok(process.env.MMAPP_TEST_CHR_PASSWORD)
const ports=process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if(ports)assert.ok(ports.length===2&&ports.every(port=>Number.isInteger(port)&&port>=1024&&port<=65535))
const marker="mcp-test-"+randomUUID().slice(0,8),resources=["gre","ipv4_address","ipv6_address","vrf","routing_table","ipv4_route","ipv6_route","routing_filter"]
const actor:AuthUser={id:"chr-gre-revision-owner",apps:["mm"],permissions:["mm:settings:admin","mm:network:admin"],expiresAt:Date.now()+7200000}
const checks:Record<string,unknown>[]=[],dispatches:{method:string;path:string;at:string}[]=[]
const report={startedAt:new Date().toISOString(),marker,complete:false,scope:"Public GRE whole-section revision restore configuration only",operationVersion:1,nativeRequestGapMs:100,forwardingVerified:false,ipv6ReadinessVerified:false,ipsecSaVerified:false,checks,dispatches}
const reportUrl=new URL("../../../../docs/mcp-chr-gre-revision-acceptance.json",import.meta.url)
let saves=Promise.resolve()
const save=()=>{const value=JSON.stringify(report,null,2)+"\n";const next=saves.then(()=>writeFile(reportUrl,value));saves=next.catch(()=>{});return next}
let pacing=Promise.resolve(),lastRequest=0
const pace=async()=>{const next=pacing.then(async()=>{const wait=100-(Date.now()-lastRequest);if(wait>0)await delay(wait);lastRequest=Date.now()});pacing=next.catch(()=>{});await next}
const originalGet=MikrotikClient.prototype.get,originalPost=MikrotikClient.prototype.post,originalPut=MikrotikClient.prototype.put,originalPatch=MikrotikClient.prototype.patch,originalDelete=MikrotikClient.prototype.delete
const failure=async(error:unknown,method:string,path:string,at:number)=>{
if(checks.filter(c=>c.phase==="native-request-failure").length>=64)return
checks.push(chrNativeFailure(error,method,path,at))
if(error instanceof MikrotikError&&error.statusCode===400&&["PUT","PATCH","DELETE"].includes(method)&&/^\/interface\/gre(?:\/\*[A-Za-z0-9]+)?$/.test(path)){
// Only our empty-PSK fixture writes reach this profile. Never retain a raw
// provider body, authorization header, credentials or arbitrary key value.
try{const value=JSON.parse(error.body);for(const field of ["message","detail"])if(typeof value?.[field]==="string"){
const detail=value[field].replaceAll(process.env.MMAPP_TEST_CHR_PASSWORD!,"[redacted]").replace(/(?:password|secret|token|private-key|ipsec-secret)\s*[=:]\s*(?:"[^"]*"|'[^']*'|[^\s;,]+)/gi,"[redacted]").slice(0,300)
checks.push({phase:"owned-gre-parser-error",method,path,field,detail})
}}catch{/* A non-JSON body is not evidence suitable for the report. */}
}
await save()
}
const dispatched=async(method:string,path:string,before?:()=>Promise<unknown>)=>{await before?.();assert.match(path,/^\/(?:interface\/gre|ip\/address|ipv6\/address)(?:\/\*[A-Za-z0-9]+)?$/);dispatches.push({method,path,at:new Date().toISOString()});await save()}
MikrotikClient.prototype.get=async function<T>(path:string,timeout?:number,signal?:AbortSignal):Promise<T>{await pace();const at=Date.now();try{
const value=await originalGet.call(this,path,timeout,signal)as T
if(["/interface","/interface/gre"].includes(path)&&Array.isArray(value)&&checks.filter(c=>c.phase==="owned-interface-shape").length<128){
const owned=value.filter(row=>row&&typeof row.name==="string"&&row.name.startsWith(marker+"-"))
if(owned.length)checks.push({phase:"owned-interface-shape",path,rows:owned.map(row=>({id:row[".id"],name:row.name,type:row.type??null,dynamic:row.dynamic??null}))})
}
return value
}catch(error){await failure(error,"GET",path,at);throw error}}
MikrotikClient.prototype.post=async function<T>(path:string,body:Record<string,string|string[]>,timeout?:number,signal?:AbortSignal):Promise<T>{
await pace();const at=Date.now()
try{
const value=await originalPost.call(this,path,body,timeout,signal)as T
if(path==="/interface/print"&&Array.isArray(body[".query"])&&checks.filter(c=>c.phase==="interface-flag-query").length<128){
checks.push({phase:"interface-flag-query",query:body[".query"],rows:Array.isArray(value)?value.map(row=>({id:row[".id"],name:row.name,type:row.type??null,dynamic:row.dynamic??null})):null})
}
if(/^\/(?:interface\/gre|ip\/address|ipv6\/address)\/get$/.test(path)&&checks.filter(c=>c.phase==="getter-shape").length<256){
const ret=value&&typeof value==="object"&&"ret"in value?value.ret:undefined
checks.push({phase:"getter-shape",path,property:body["value-name"],array:Array.isArray(value),records:Array.isArray(value)?value.length:null,retType:typeof ret,retEmpty:ret===""})
}
return value
}catch(error){
if(path.endsWith("/get")&&typeof body["value-name"]==="string")checks.push({phase:"getter-failure",path,property:body["value-name"],nativeStatus:error instanceof MikrotikError?error.statusCode:null})
await failure(error,"POST",path,at);throw error
}
}
MikrotikClient.prototype.put=async function<T>(path:string,body:Record<string,string>,timeout?:number,before?:()=>Promise<unknown>):Promise<T>{await pace();const at=Date.now();try{return await originalPut.call(this,path,body,timeout,()=>dispatched("PUT",path,before))as T}catch(error){await failure(error,"PUT",path,at);throw error}}
MikrotikClient.prototype.patch=async function<T>(path:string,body:Record<string,string>,timeout?:number,before?:()=>Promise<unknown>):Promise<T>{await pace();const at=Date.now();try{return await originalPatch.call(this,path,body,timeout,()=>dispatched("PATCH",path,before))as T}catch(error){await failure(error,"PATCH",path,at);throw error}}
MikrotikClient.prototype.delete=async function(path:string,timeout?:number,before?:()=>Promise<unknown>):Promise<void>{await pace();const at=Date.now();try{return await originalDelete.call(this,path,timeout,()=>dispatched("DELETE",path,before))}catch(error){await failure(error,"DELETE",path,at);throw error}}
let activeOperation:string|undefined
try{
await save();await applySqlMigrations(pool);assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0)
await updateMcpSettings({enabled:true},actor)
for(const[index,host]of hosts.entries())await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)",[index+1,marker,ports?"127.0.0.1":host,ports?.[index]??80,process.env.MMAPP_TEST_CHR_USER,process.env.MMAPP_TEST_CHR_PASSWORD])
const issued=await createMcpConnection(mcpConnectionCreateSchema.parse({name:marker,mode:"approval",expiresAt:new Date(Date.now()+7200000).toISOString(),permissions:["mm:network:read","mm:network:write"],scope:{serverIds:[1,2],operationIds:["network.gre.revision.restore","operations.reconcile","ros.gre.update","ros.gre.delete","ros.ipv4_address.delete","ros.ipv6_address.delete"],allowGlobal:false},limits:{callsPerMinute:1000,mutationsPerHour:500}}),actor)
const principal=await authenticateMcpToken(issued.token)
const execute=async(tool:string,input:Record<string,unknown>)=>{
const trace:Record<string,unknown>={phase:"mcp-operation",tool,serverId:input.serverId??null,stage:"prepare"};checks.push(trace);await save()
const plan=await invokeMcpTool(principal,tool,input)as{planId:string;state:string}
assert.equal(plan.state,"awaiting-approval");trace.planId=plan.planId
await assert.rejects(applyPlan(principal,plan.planId,randomUUID()));await approvePlan(plan.planId,actor,true)
const key=randomUUID(),operation=await applyPlan(principal,plan.planId,key);activeOperation=operation.id
trace.operationId=operation.id;trace.stage="running";await save()
assert.equal((await applyPlan(principal,plan.planId,key)).id,operation.id)
for(let attempt=0;attempt<480;attempt++){
const row=(await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1",[operation.id])).rows[0]
if(!["pending","running"].includes(row.state)){
activeOperation=undefined;trace.stage="terminal";trace.state=row.state;trace.errorCode=row.error_code
const steps=(await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index",[operation.id])).rows
trace.receipts=steps.map(row=>row.result);await save()
if(row.state!=="applied"){
try{const {server}=await routerClient(Number(input.serverId));await readGreRestoreState(server);checks.push({phase:"failed-operation-read-diagnostic",readable:true})}
catch(error){const message=error instanceof Error?error.message:"";checks.push({phase:"failed-operation-read-diagnostic",readable:false,reason:/^(?:Неполный native-каталог|Повторяющиеся native ID|Не подтверждено |Не подтверждены |Противоречивое происхождение|Restore требует|Истёк |Превышен )/.test(message)?message.slice(0,240):"unclassified"})}
await save()
}
assert.equal(row.state,"applied",`${tool}: ${row.state}/${row.error_code}`)
return{operationId:operation.id,result:steps[0]?.result as {verified?:boolean;commandCount?:number;completedCommands?:number;revisionRecorded?:boolean;forwardingVerified?:boolean}|undefined}
}
await delay(500)
}
throw Error("Native operation remains live; do not replay or race cleanup")
}
const baselines:RosSnapshot[]=[],proofs=new Map<number,string>()
// Both devices must pass before the first mutation on either one.
for(const serverId of [1,2]){
const {server,client}=await routerClient(serverId),state=await readGreRestoreState(server)
assert.equal(state.gre.length,0,"Whole-section test refuses any foreign GRE")
proofs.set(serverId,await greRestoreStateProof(state))
const system=await client.get<Record<string,unknown>>("/system/resource")
for(const resource of resources)baselines.push(await readRosSnapshot(serverId,resource))
checks.push({phase:"preflight-device",serverId,routerOs:system.version,emptyGre:true,stateProof:proofs.get(serverId)})
}
checks.push({phase:"preflight",baselineFingerprints:baselines.map(({serverId,resource,fingerprint})=>({serverId,resource,fingerprint})),foreignGreAbsent:true});await save()
for(const serverId of [1,2]){
const {server,client}=await routerClient(serverId),a=marker+"-a",b=marker+"-b",a4=`198.18.251.${serverId}/30`,b4=`198.18.252.${serverId}/30`,a6=`fd00:ab:46:${serverId}::1/64`
const allowedAddresses=new Set([a4,b4,a6]),ownedIds=new Set<string>()
const first=canonicalGreSnapshot({tunnels:[{name:a,localAddress:"",remoteAddress:"198.51.100.253",localInnerIp:a4,innerAddresses:[{address:a4,disabled:false,comment:marker},{address:a6,disabled:false,advertise:false,noDad:false,comment:marker}],remoteInnerIp:"",comment:marker,disabled:true,mtu:1400,keepalive:"10s,10",dscp:"inherit",clampTcpMss:true,allowFastPath:true,ipsecSecret:""}]})
const second=canonicalGreSnapshot({tunnels:[{...first.tunnels[0],mtu:1390},{...first.tunnels[0],name:b,remoteAddress:"198.51.100.254",localInnerIp:b4,innerAddresses:[{address:b4,disabled:false,comment:marker}],mtu:1380}]})
const revision=async(payload:unknown)=>{const id=randomUUID();await pool.query("INSERT INTO config_revisions(id,server_id,section,source,fingerprint,payload) VALUES($1,$2,'gre','copy',$3,$4::jsonb)",[id,serverId,fingerprintPayload(payload),JSON.stringify(payload)]);return id}
const aRevision=await revision(first),bRevision=await revision(second),emptyRevision=await revision({tunnels:[]})
let attempted=false
try{
for(const path of ["/ip/address","/ipv6/address"])assert.ok(!(await client.get<Record<string,string>[]>(path)).some(row=>allowedAddresses.has(row.address)),"Fixture address collision")
attempted=true
const created=await execute("mm_gre_revision_restore_plan",{serverId,revisionId:aRevision})
assert.equal(created.result?.verified,true);assert.equal(created.result.commandCount,3)
let rows=await client.get<Record<string,string>[]>("/interface/gre")
assert.equal(rows.length,1);assert.equal(rows[0].name,a);assert.equal(rows[0].disabled,"true");assert.equal(rows[0].mtu,"1400");ownedIds.add(rows[0][".id"])
assert.ok((await client.get<Record<string,string>[]>("/ip/address")).some(row=>row.interface===a&&row.address===a4))
assert.ok((await client.get<Record<string,string>[]>("/ipv6/address")).some(row=>row.interface===a&&row.address===a6&&row.advertise!=="true"))
const beforeNoop=dispatches.length,noop=await execute("mm_gre_revision_restore_plan",{serverId,revisionId:aRevision})
assert.equal(noop.result?.commandCount,0);assert.equal(dispatches.length,beforeNoop)
const expanded=await execute("mm_gre_revision_restore_plan",{serverId,revisionId:bRevision})
assert.equal(expanded.result?.commandCount,3);rows=await client.get<Record<string,string>[]>("/interface/gre")
assert.equal(rows.length,2);assert.ok(rows.every(row=>[a,b].includes(row.name)&&row.disabled==="true"&&row.comment===marker))
for(const row of rows)ownedIds.add(row[".id"])
assert.equal(rows.find(row=>row.name===a)![".id"],[...ownedIds][0]);assert.equal(rows.find(row=>row.name===a)!.mtu,"1390")
const beforeReconcile=dispatches.length,reconciled=await invokeMcpTool(principal,"mm_operation_reconcile",{operationId:expanded.operationId})as{results:{matchesDesiredState:boolean}[]}
assert.equal(reconciled.results[0].matchesDesiredState,true);assert.equal(dispatches.length,beforeReconcile)
const restored=await execute("mm_gre_revision_restore_plan",{serverId,revisionId:aRevision})
assert.equal(restored.result?.commandCount,3);rows=await client.get<Record<string,string>[]>("/interface/gre")
assert.equal(rows.length,1);assert.equal(rows[0].name,a);assert.equal(rows[0].mtu,"1400");assert.ok(!(await client.get<Record<string,string>[]>("/ip/address")).some(row=>row.interface===b))
const cleared=await execute("mm_gre_revision_restore_plan",{serverId,revisionId:emptyRevision})
assert.equal(cleared.result?.commandCount,3);assert.equal((await client.get<unknown[]>("/interface/gre")).length,0)
assert.equal(cleared.result.revisionRecorded,true)
checks.push({phase:"revision-cycle",serverId,firstCommands:3,noOpCommands:0,expandCommands:3,restoreCommands:3,emptyCommands:3,disabledThroughout:true,dualStackConfigured:true,secondGreRemoved:true,readonlyReconcile:true,explicitEmptyPsk:true,forwardingVerified:false});await save()
}finally{
if(activeOperation){checks.push({phase:"cleanup-refused-live-operation",serverId,operationId:activeOperation});await save();throw Error("Unfinished native operation; cleanup cannot race it")}
if(attempted){
for(const gre of await client.get<Record<string,string>[]>("/interface/gre"))if([a,b].includes(gre.name)){
assert.ok(gre.comment===marker||ownedIds.has(gre[".id"]));assert.equal(gre["remote-address"],gre.name===a?"198.51.100.253":"198.51.100.254")
if(gre.disabled!=="true")await execute("mm_gre_update_plan",{serverId,id:gre[".id"],fields:{disabled:true}})
for(const[resource,path]of [["ipv4_address","/ip/address"],["ipv6_address","/ipv6/address"]])for(const address of await client.get<Record<string,string>[]>(path))if(address.interface===gre.name&&address.dynamic!=="true"){
assert.ok(allowedAddresses.has(address.address),"Refuse an unexpected address during cleanup")
await execute(`mm_${resource}_delete_plan`,{serverId,id:address[".id"]})
}
await execute("mm_gre_delete_plan",{serverId,id:gre[".id"]})
}
for(const baseline of baselines.filter(row=>row.serverId===serverId))assert.equal((await readRosSnapshot(serverId,baseline.resource)).fingerprint,baseline.fingerprint,`Baseline changed: ${serverId}:${baseline.resource}`)
const afterProof=await greRestoreStateProof(await readGreRestoreState(server))
assert.equal(afterProof,proofs.get(serverId),"Whole registered native/SQL dependency proof changed after cleanup")
checks.push({phase:"baseline-restored",serverId,fingerprints:resources.length,wholeDependencyProofRestored:true,ownedObjectsRemoved:true});await save()
}
}
console.log(`✓ CHR ${serverId}: public GRE revision create/no-op/expand/restore/empty, dual-stack configuration, readonly reconcile and original dependency proof restored`)
}
assert.equal(dispatches.length,24)
report.complete=true;checks.push({phase:"all-baselines-restored",fingerprints:baselines.length,wholeDependencyProofs:2,configurationOnly:true});await save()
}catch(error){
checks.push({phase:"failure",name:error instanceof Error?error.name:"unknown",code:error&&typeof error==="object"&&"code"in error?String(error.code).slice(0,80):"TEST_STAGE_UNCONFIRMED"});process.exitCode=1
}finally{
Object.assign(report,{finishedAt:new Date().toISOString()});await save()
MikrotikClient.prototype.get=originalGet;MikrotikClient.prototype.post=originalPost;MikrotikClient.prototype.put=originalPut;MikrotikClient.prototype.patch=originalPatch;MikrotikClient.prototype.delete=originalDelete
await closePool()
}
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import { MikrotikError } from "../../services/mikrotik.js"
import { chrNativeFailure } from "./chr-native-failure.js"
const secret = "private-test-secret"
const http = chrNativeFailure(new MikrotikError(401, "/ip/address", secret), "GET", "/ip/address", Date.now())
assert.equal(http.reason, "http"); assert.equal(http.nativeStatus, 401)
assert.equal(JSON.stringify(http).includes(secret), false)
assert.equal(chrNativeFailure(new Error(`Connection to ${secret} timed out after 10s`), "GET", "/ip/address", Date.now()).reason, "timeout")
for (const [code, reason] of [["ECONNRESET", "connection_reset"], ["ECONNREFUSED", "connection_refused"], ["EAI_AGAIN", "dns"]]) assert.equal(chrNativeFailure(Object.assign(new Error(secret), { code }), "POST", "/routing/id/get", Date.now()).reason, reason)
assert.equal(chrNativeFailure(new Error(secret), secret, "/ip/address?password=" + secret, Date.now()).path, "[unclassified]")
assert.equal(chrNativeFailure(new Error(secret), secret, secret, Date.now()).method, "unknown")
assert.equal(chrNativeFailure(new DOMException(secret, "AbortError"), "GET", "/ip/address", Date.now()).reason, "aborted")
assert.equal(http.automaticRetry, false)
console.log("CHR failure diagnostics: classified transport/HTTP metadata, private payloads excluded")
@@ -0,0 +1,20 @@
import { MikrotikError } from "../../services/mikrotik.js"
/** Acceptance diagnostics only: never retain messages, bodies, headers or request arguments. */
export function chrNativeFailure(error: unknown, method: string, path: string, startedAt: number) {
const text = error instanceof Error ? error.message : ""
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined
const reason = error instanceof MikrotikError ? "http"
: code === "ECONNRESET" ? "connection_reset"
: code === "ECONNREFUSED" ? "connection_refused"
: code === "ENOTFOUND" || code === "EAI_AGAIN" ? "dns"
: code === "ETIMEDOUT" || /timed out|timeout/i.test(text) ? "timeout"
: error instanceof Error && error.name === "AbortError" ? "aborted" : "transport_unknown"
return {
phase: "native-request-failure", at: new Date().toISOString(),
method: ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method) ? method : "unknown",
path: /^\/(?:routing|ip|ipv6|interface|system|tool|ping)(?:\/[a-z0-9_.*-]+)*$/i.test(path) && path.length <= 300 ? path : "[unclassified]",
reason, ...(error instanceof MikrotikError ? { nativeStatus: error.statusCode } : {}),
elapsedMs: Math.max(0, Date.now() - startedAt), automaticRetry: false,
}
}
@@ -0,0 +1,123 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { McpPrincipal } from "./store.js"
import { requireOspfBfdSession } from "./ospf-health.js"
import { pool } from "../../db/index.js"
import { readRoutingObservations } from "./routing-observations.js"
import { chrFixtureVrf, chrOspfForwarding } from "./chr-fixture-context.js"
import { startChrOspfForwarding } from "./chr-ospf-forwarding.js"
type Execute = (tool: string, args: Record<string, unknown>) => Promise<string>
export async function cleanupChrOspf(marker: string, execute: Execute) {
assert.match(marker, /^mcp-test-[0-9a-f]{8}$/)
const errors: unknown[] = []
for (const serverId of [1, 2]) try {
for (const resource of ["ospf_template", "ospf_area", "ospf_instance", "bfd_configuration"]) {
const own = (await readRosSnapshot(serverId, resource)).rows.filter(row => row.comment === marker)
for (const row of own) {
if (resource === "ospf_template") {
assert.ok([marker + "-gre", ...(chrOspfForwarding() ? [marker + "-bgp-loop"] : [])].includes(String(row.interfaces)))
if (row.interfaces === marker + "-bgp-loop") assert.ok([true, false, "true", "false"].includes(row.passive as string | boolean))
assert.ok([marker + "-area-v2", marker + "-area-v3"].includes(String(row.area)))
}
if (resource === "ospf_area") assert.ok([marker + "-ospf-v2", marker + "-ospf-v3"].includes(String(row.instance)))
if (resource === "ospf_instance" || resource === "bfd_configuration") assert.equal(row.vrf, chrFixtureVrf(marker, serverId))
if (resource === "bfd_configuration") assert.equal(row.interfaces, marker + "-gre")
await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
}
}
} catch (error) { errors.push(error) }
if (errors.length) throw new AggregateError(errors, "Owned OSPF/BFD cleanup incomplete")
}
/** Runs inside the owned overlay; no redistribution, main table or public OSPF. */
export async function acceptChrOspf(marker: string, principal: McpPrincipal, execute: Execute, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
const gre = marker + "-gre", vrf = chrFixtureVrf(marker, 1), versions = ["2", "3"] as const
const resources = ["ospf_template", "ospf_area", "ospf_instance", "bfd_configuration"]
const baseline = []
const localV4: string[] = [], localV6: string[] = []
for (const serverId of [1, 2]) {
for (const resource of resources) baseline.push(await readRosSnapshot(serverId, resource))
const { client } = await routerClient(serverId)
const v4 = await client.get<Record<string, string>[]>("/ip/address"), v6 = await client.get<Record<string, string>[]>("/ipv6/address")
localV4.push(v4.find(row => row.interface === gre && row.comment === marker)!.address.split("/")[0])
const linkLocal = v6.filter(row => row.interface === gre && /^fe80:/i.test(row.address))
assert.equal(linkLocal.length, 1); localV6.push(linkLocal[0].address.split("/")[0])
}
checks.push({ ospfPreflight: true, baselineFingerprints: baseline.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })) }); await checkpoint()
try {
for (const serverId of [1, 2]) {
const vrf = chrFixtureVrf(marker, serverId)
const peer = serverId === 1 ? 1 : 0
for (const address of [localV4[peer] + "/32", localV6[peer] + "/128"]) await execute("mm_bfd_configuration_create_plan", { serverId, fields: { interfaces: gre, addresses: address, vrf, "min-rx": "1s", "min-tx": "1s", multiplier: 3, comment: marker } })
for (const version of versions) {
const instance = marker + "-ospf-v" + version, area = marker + "-area-v" + version
await execute("mm_ospf_instance_create_plan", { serverId, fields: { name: instance, version, "router-id": localV4[serverId - 1], vrf, "routing-table": vrf, disabled: false, comment: marker } })
await execute("mm_ospf_area_create_plan", { serverId, fields: { name: area, instance, "area-id": "0.0.0.0", type: "default", disabled: false, comment: marker } })
await execute("mm_ospf_template_create_plan", { serverId, fields: { interfaces: gre, area, type: "ptp", cost: 30, "use-bfd": true, "hello-interval": "1s", "dead-interval": "4s", disabled: false, comment: marker } })
if (chrOspfForwarding()) await execute("mm_ospf_template_create_plan", { serverId, fields: { interfaces: marker + "-bgp-loop", area, passive: true, cost: 30, disabled: false, comment: marker } })
}
console.log(`✓ CHR ${serverId}: isolated OSPFv2/v3 and bounded BFD rules configured`)
}
for (const serverId of [1, 2]) {
const vrf = chrFixtureVrf(marker, serverId)
const { client } = await routerClient(serverId)
for (const version of versions) {
const instance = marker + "-ospf-v" + version
for (let attempt = 0; attempt < 30; attempt++) {
const neighbors = await client.getOspfNeighbors(), sessions = await client.getBfdSessions()
const matching = neighbors.filter(row => row.instance === instance && row.interface === gre && row.state.toLowerCase() === "full")
let proof: unknown = null
if (matching.length === 1) try { proof = requireOspfBfdSession(sessions as unknown as Record<string, unknown>[], matching[0].address, gre, vrf) } catch { /* bounded observation wait; never retries a write */ }
if (proof) {
checks.push({ serverId, phase: "ospf-full-bfd-up", version, neighbor: { instance, address: matching[0].address, interface: gre, state: matching[0].state }, bfd: proof }); await checkpoint(); break
}
if (attempt === 29) { checks.push({ serverId, phase: "ospf-unconfirmed", version, neighbors, sessions }); await checkpoint(); assert.fail("OSPF Full/BFD Up not confirmed in the isolated VRF") }
await delay(2000)
}
}
}
const forwarding = chrOspfForwarding() ? await startChrOspfForwarding(marker, principal, checks, checkpoint) : null
for (const family of ["ipv4", "ipv6"] as const) {
await execute("mm_routing_measurement_plan", { measurement: { srcServerId: 1, dstServerId: 2, srcInterface: gre, dstInterface: gre, family, vrf, bandwidth: false } })
if (process.env.MMAPP_TEST_CHR_OSPF_SOURCES === "true") {
const latest = async () => (await readRoutingObservations([1, 2])).find(row => row.family === family && row.srcInterface === gre && row.dstInterface === gre)!
assert.equal((await latest()).sourceVerified, true); assert.equal((await latest()).contextVerified, true)
// Only the disposable manager DB changes; native credentials/TLS stay untouched.
await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=2")
assert.equal((await latest()).sourceVerified, false)
await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=2")
assert.equal((await latest()).sourceVerified, false, "Returning to previous parameters must not revive a measurement")
await execute("mm_routing_measurement_plan", { measurement: { srcServerId: 1, dstServerId: 2, srcInterface: gre, dstInterface: gre, family, vrf, bandwidth: false } })
const fresh = await latest()
assert.equal(fresh.sourceVerified, true); assert.equal(fresh.contextVerified, true)
checks.push({ phase: "measurement-source-epoch", family, signedFreshMeasurement: true, peerDriftRejected: true, abaRejected: true, freshMeasurementRestoresEligibility: true, pingMs: fresh.pingMs, lossPct: fresh.lossPct }); await checkpoint()
}
const operationId = await execute("mm_ospf_optimizer_plan", { serverId: 1, family, table: vrf, ecmpPaths: 1, pingWeight: 100 })
const version = family === "ipv4" ? "2" : "3", other = family === "ipv4" ? "3" : "2"
const rows = (await readRosSnapshot(1, "ospf_template")).rows
assert.equal(rows.find(row => row.area === marker + "-area-v" + version && row.interfaces === gre)!.cost, "10")
assert.equal(rows.find(row => row.area === marker + "-area-v" + other && row.interfaces === gre)!.cost, "30", "Optimizer crossed the requested family")
if (forwarding) await forwarding.sample(family, "after-cost", -20)
await execute("mm_operation_recovery_plan", { operationId })
if (forwarding) await forwarding.sample(family, "after-inverse", 0)
checks.push({ family, optimizerApplied: true, selectedCost: 10, foreignFamilyUntouched: true, inverseCostRestored: true, bfdHealthVerified: true }); await checkpoint()
}
if (forwarding) for (const family of ["ipv4", "ipv6"] as const) {
const version = family === "ipv4" ? "2" : "3"
const loopTemplate = (await readRosSnapshot(1, "ospf_template")).rows.find(row => row.area === marker + "-area-v" + version && row.interfaces === marker + "-bgp-loop" && row.comment === marker)!
assert.equal(loopTemplate.passive, "true")
await execute("mm_ospf_template_update_plan", { serverId: 1, id: loopTemplate[".id"], fields: { passive: false } })
assert.equal((await readRosSnapshot(1, "ospf_template")).rows.find(row => row[".id"] === loopTemplate[".id"])!.passive, "false")
await execute("mm_ospf_template_update_plan", { serverId: 1, id: loopTemplate[".id"], fields: { passive: true } })
assert.equal((await readRosSnapshot(1, "ospf_template")).rows.find(row => row[".id"] === loopTemplate[".id"])!.passive, "true")
await forwarding.sample(family, "after-passive-restore", 0)
checks.push({ phase: "ospf-passive-toggle", family, falseVerified: true, trueRestored: true }); await checkpoint()
}
} finally {
await cleanupChrOspf(marker, execute)
for (const snapshot of baseline) assert.equal((await readRosSnapshot(snapshot.serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "OSPF/BFD baseline changed after cleanup")
checks.push({ ospfBaselineRestored: true }); await checkpoint()
}
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import type { readOspfRouteProof } from "../../services/ospf-route-proof.js"
import { chrFixtureVrf } from "./chr-fixture-context.js"
import { readRosSnapshot } from "./resources.js"
import { McpError } from "./security.js"
import { invokeMcpTool } from "./runtime.js"
import type { McpPrincipal } from "./store.js"
export async function startChrOspfForwarding(marker: string, principal: McpPrincipal, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
const gre = marker + "-gre", loop = marker + "-bgp-loop", subnet = Number.parseInt(marker.slice(-2), 16), hextet = Number.parseInt(marker.slice(-4), 16).toString(16)
const baseline = new Map<string, number>()
const loopAddress = (serverId: number, family: string) => family === "ipv4" ? `198.19.${subnet}.${serverId}` : `fd6d:6370:${hextet}:2::${serverId}`
const linkAddress = (serverId: number, family: string) => family === "ipv4" ? `198.18.${subnet}.${serverId}` : `fd6d:6370:${hextet}:1::${serverId}`
const sample = async (family: "ipv4" | "ipv6", phase: string, delta: number) => {
for (const serverId of [1, 2]) {
const peerId = 3 - serverId, table = chrFixtureVrf(marker, serverId)
const prefix = loopAddress(peerId, family) + (family === "ipv4" ? "/32" : "/128"), key = serverId + ":" + family
let proof: Awaited<ReturnType<typeof readOspfRouteProof>> | undefined
for (let attempt = 0; attempt < 20; attempt++) {
try {
const result = await invokeMcpTool(principal, "mm_ospf_route_proof_get", { serverId, prefix, table, interface: gre }) as { route: Awaited<ReturnType<typeof readOspfRouteProof>> }
const observed = result.route
const expected = baseline.get(key)
if (expected !== undefined && observed.metric !== expected + (serverId === 1 ? delta : 0)) throw new Error("OSPF route metric has not converged")
proof = observed; break
} catch (error) {
if (error instanceof McpError && error.code !== "STATE_UNAVAILABLE") throw error
// Only repeat bounded reads; never repeat an optimizer/inverse write.
if (attempt === 19) {
const reason = error instanceof Error && error.message === "OSPF route metric has not converged" ? "metric-not-converged" : "native-read-unconfirmed"
checks.push({ phase: "ospf-forward-unconfirmed", serverId, family, table, prefix, stage: phase, reason }); await checkpoint()
throw new Error("Native OSPF forwarding route or metric was not confirmed")
}
await delay(1000)
}
}
assert.ok(proof)
if (!baseline.has(key)) baseline.set(key, proof.metric)
checks.push({ phase: "ospf-forward-rib", stage: phase, serverId, family, proof, expectedMetricDelta: serverId === 1 ? delta : 0 }); await checkpoint()
const ownGre = (await readRosSnapshot(serverId, "gre")).rows.find(row => row.name === gre && row.comment === marker)!
const profile = { name: marker, serverId, community: "65000:65000", family, table, probeVrf: table, candidates: [{ id: "ospf-loop", jumpHostId: peerId, exitNodeId: peerId, wanInterface: gre, downstreamInterface: gre, gateway: linkAddress(peerId, family), gatewayRef: { resource: "gre", id: ownGre[".id"] }, controlProbe: { targetInterface: loop, targetAddress: loopAddress(peerId, family), sourceAddress: linkAddress(serverId, family), expectedHops: [{ serverId: peerId, interface: loop, address: loopAddress(peerId, family) }] } }] }
const packet = await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "ospf-loop", proofPurpose: "control-path" }) as { status: string; packetCounts?: { sent: number; received: number }; trace?: { verified: boolean } }
checks.push({ phase: "ospf-forward-packets", stage: phase, serverId, family, proof: packet, sourceInterface: gre, destinationInterface: loop, returnPath: "connected-gre", independentPaths: false }); await checkpoint()
assert.equal(packet.status, "passed"); assert.deepEqual(packet.packetCounts, { sent: 3, received: 3 }); assert.equal(packet.trace?.verified, true)
}
}
await sample("ipv4", "baseline", 0); await sample("ipv6", "baseline", 0)
return { sample }
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { MikrotikClient } from "../../services/mikrotik.js"
import { readOspfIdentityCatalog, ospfLiteralRouterId } from "../../services/ospf-identity-catalog.js"
import { readOspfLinkCatalog } from "../../services/ospf-link-catalog.js"
import { closePool } from "../../db/index.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",")
assert.deepEqual(hosts, ["194.226.121.150", "104.165.244.7"])
const username = process.env.MMAPP_TEST_CHR_USER, password = process.env.MMAPP_TEST_CHR_PASSWORD
assert.ok(username && password)
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
const checks: Record<string, unknown>[] = [], report = { startedAt: new Date().toISOString(), complete: false, deviceMutations: 0, fullOspfAcceptance: false, nativeMcpAdmissionTested: false, checks }
const linkRead = process.env.MMAPP_TEST_CHR_OSPF_LINK_READONLY === "true"
try {
for (const [index, host] of hosts.entries()) {
const client = new MikrotikClient({ host: ports ? "127.0.0.1" : host, port: ports?.[index] ?? 80, username, password, useSsl: false, verifySsl: true })
const system = await client.get<Record<string, unknown>>("/system/resource")
const actual = await readOspfIdentityCatalog(client, () => delay(500))
if (linkRead) for (const family of ["ipv4", "ipv6"] as const) {
const link = await readOspfLinkCatalog(client, family, () => delay(500))
checks.push({ host, phase: "link-source-read", family, actualOspfInstances: link.identity.instances.length, neighbors: link.neighbors.length, addresses: link.addresses.length, nativeAddressFlagsRead: true, deviceMutations: 0 })
}
// No OSPF instance is created. Exercise the real named-ID source with a
// synthetic consumer; every routing/id request remains an actual native read.
const adapter = {
async get<T>(path: string): Promise<T> {
if (path === "/routing/ospf/instance") return [{ ".id": "*fixture", name: "synthetic-readonly", "router-id": "main", version: "2", vrf: "main", disabled: "false" }] as T
assert.equal(path, "/routing/id")
return client.get<T>(path)
},
async post<T>(path: string, body: Record<string, string>): Promise<T> {
assert.equal(path, "/routing/id/get")
assert.ok(["disabled", "inactive"].includes(body["value-name"]))
return client.post<T>(path, body)
},
}
const elected = await readOspfIdentityCatalog(adapter, () => delay(500))
assert.ok(ospfLiteralRouterId(elected.instances[0]["router-id"]))
checks.push({ host, routerOs: system.version, uptime: system.uptime, actualOspfInstances: actual.instances.length, actualCatalogRead: true, actualNamedIdSourceRead: true, syntheticOspfConsumer: true, resolvedRouterId: elected.instances[0]["router-id"], namedReference: "main", deviceMutations: 0 })
console.log(`✓ ${host}: actual OSPF catalog and routing/id named reference read; no native writes`)
}
report.complete = true
} finally {
await writeFile(new URL(linkRead ? "../../../../docs/mcp-chr-ospf-link-readonly.json" : "../../../../docs/mcp-chr-ospf-identity-readonly.json", import.meta.url), JSON.stringify({ ...report, finishedAt: new Date().toISOString() }, null, 2) + "\n")
await closePool()
}
@@ -0,0 +1,137 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { MikrotikClient } from "../../services/mikrotik.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient, type RosSnapshot } from "./resources.js"
import { listActions } from "./actions.js"
import { chrNativeFailure } from "./chr-native-failure.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts=(process.env.MMAPP_TEST_CHR_HOSTS??"").split(",")
assert.deepEqual(hosts,["194.226.121.150","104.165.244.7"])
const username=process.env.MMAPP_TEST_CHR_USER,password=process.env.MMAPP_TEST_CHR_PASSWORD
assert.equal(username,"admin");assert.ok(password)
const ports=process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if(ports)assert.ok(ports.length===2&&ports.every(port=>Number.isInteger(port)&&port>=1024&&port<=65535))
const marker="mcp-test-"+randomUUID().slice(0,8)
const resources=["ospf_template","ospf_area","ospf_instance","vrf","routing_table","bridge","bridge_port","bridge_vlan","ipv4_address","ipv6_address","ipv4_route","ipv6_route"]
const actor:AuthUser={id:"chr-legacy-ospf-owner",apps:["mm"],permissions:["mm:settings:admin","mm:network:admin"],expiresAt:Date.now()+7200000}
const checks:Record<string,unknown>[]=[],costPatches:{path:string;at:string}[]=[]
const report={startedAt:new Date().toISOString(),marker,complete:false,scope:"Legacy OSPF public apply/restore configuration only",tools:listActions().length,forwardingVerified:false,healthVerified:false,threeNodeFailover:false,nativeRequestGapMs:500,checks,costPatches}
const reportUrl=new URL("../../../../docs/mcp-chr-ospf-legacy-acceptance.json",import.meta.url)
let queue=Promise.resolve()
const save=()=>{const value=JSON.stringify(report,null,2)+"\n";queue=queue.catch(()=>{}).then(()=>writeFile(reportUrl,value));return queue}
let pacing=Promise.resolve(),lastRequest=0
const pace=async()=>{const next=pacing.then(async()=>{const wait=500-(Date.now()-lastRequest);if(wait>0)await delay(wait);lastRequest=Date.now()});pacing=next.catch(()=>{});await next}
const originalGet=MikrotikClient.prototype.get,originalPost=MikrotikClient.prototype.post,originalPatch=MikrotikClient.prototype.patch,originalPut=MikrotikClient.prototype.put,originalDelete=MikrotikClient.prototype.delete
const failure=async(error:unknown,method:string,path:string,started:number)=>{if(checks.filter(row=>row.phase==="native-request-failure").length<64){checks.push(chrNativeFailure(error,method,path,started));await save()}}
MikrotikClient.prototype.get=async function<T>(path:string,timeout?:number,signal?:AbortSignal):Promise<T>{await pace();const at=Date.now();try{return await originalGet.call(this,path,timeout,signal)as T}catch(error){await failure(error,"GET",path,at);throw error}}
MikrotikClient.prototype.post=async function<T>(path:string,body:Record<string,string|string[]>,timeout?:number,signal?:AbortSignal):Promise<T>{await pace();const at=Date.now();try{return await originalPost.call(this,path,body,timeout,signal)as T}catch(error){await failure(error,"POST",path,at);throw error}}
MikrotikClient.prototype.patch=async function<T>(path:string,body:Record<string,string>,timeout?:number,beforeDispatch?:()=>Promise<unknown>):Promise<T>{
await pace();const at=Date.now()
try{return await originalPatch.call(this,path,body,timeout,async()=>{await beforeDispatch?.();costPatches.push({path,at:new Date().toISOString()})})as T}catch(error){await failure(error,"PATCH",path,at);throw error}
}
MikrotikClient.prototype.put=async function<T>(path:string,body:Record<string,string>,timeout?:number,beforeDispatch?:()=>Promise<unknown>):Promise<T>{await pace();const at=Date.now();try{return await originalPut.call(this,path,body,timeout,beforeDispatch)as T}catch(error){await failure(error,"PUT",path,at);throw error}}
MikrotikClient.prototype.delete=async function(path:string,timeout?:number,beforeDispatch?:()=>Promise<unknown>):Promise<void>{await pace();const at=Date.now();try{return await originalDelete.call(this,path,timeout,beforeDispatch)}catch(error){await failure(error,"DELETE",path,at);throw error}}
let activeOperation:string|undefined
try{
await save();await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0)
for(const[index,host]of hosts.entries())await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)",[index+1,marker,ports?"127.0.0.1":host,ports?.[index]??80,username,password])
await updateMcpSettings({enabled:true},actor)
const operationIds=[...["bridge","vrf","ospf_instance","ospf_area","ospf_template"].flatMap(resource=>["create","delete"].map(action=>`ros.${resource}.${action}`)),"routing.ospf.application.optimize","routing.ospf.application.restore","operations.reconcile"]
const issued=await createMcpConnection(mcpConnectionCreateSchema.parse({name:marker,mode:"approval",expiresAt:new Date(Date.now()+7200000).toISOString(),permissions:["mm:network:read","mm:network:write"],scope:{serverIds:[1,2],operationIds,allowGlobal:false},limits:{callsPerMinute:1000,mutationsPerHour:500}}),actor)
const principal=await authenticateMcpToken(issued.token)
const execute=async(tool:string,args:Record<string,unknown>)=>{
const trace:Record<string,unknown>={phase:"mcp-operation",tool,serverId:args.serverId??null,stage:"prepare"};checks.push(trace);await save()
const plan=await invokeMcpTool(principal,tool,args)as{planId:string;state:string}
trace.planId=plan.planId;assert.equal(plan.state,"awaiting-approval")
await assert.rejects(applyPlan(principal,plan.planId,randomUUID()));await approvePlan(plan.planId,actor,true)
const key=randomUUID(),operation=await applyPlan(principal,plan.planId,key)
activeOperation=operation.id;trace.operationId=operation.id;trace.stage="running";await save()
assert.equal((await applyPlan(principal,plan.planId,key)).id,operation.id)
for(let attempt=0;attempt<480;attempt++){
const row=(await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1",[operation.id])).rows[0]
if(!["pending","running"].includes(row.state)){
activeOperation=undefined;trace.stage="terminal";trace.state=row.state;trace.errorCode=row.error_code;await save()
assert.equal(row.state,"applied",`MCP operation ended ${row.state}`)
return operation.id
}
await delay(500)
}
throw Error("Native operation remains live; cleanup must not race its writes")
}
const baselines:RosSnapshot[]=[]
for(const serverId of [1,2]){
const {client}=await routerClient(serverId),system=await client.get<Record<string,unknown>>("/system/resource")
checks.push({phase:"system",serverId,routerOs:system.version})
for(const resource of resources){const snapshot=await readRosSnapshot(serverId,resource);baselines.push(snapshot);assert.ok(!snapshot.rows.some(row=>row.comment===marker||String(row.name??"").startsWith(marker)))}
assert.equal(baselines.find(row=>row.serverId===serverId&&row.resource==="ospf_template")!.rows.length,0,"Refuse foreign OSPF templates before any fixture mutation")
assert.equal((await client.get<unknown[]>("/routing/ospf/neighbor")).length,0,"Configuration-only test requires no existing neighbors")
}
checks.push({phase:"preflight",foreignTemplatesAbsent:true,neighborsAbsent:true,baselineFingerprints:baselines.map(({serverId,resource,fingerprint})=>({serverId,resource,fingerprint}))});await save()
for(const serverId of [1,2]){
const bridges=[marker+"-a",marker+"-b"],vrf=marker+"-vrf",instance=marker+"-instance",area=marker+"-area"
let attempted=false,ownedTableId:string|undefined
try{
attempted=true
for(const name of bridges)await execute("mm_bridge_create_plan",{serverId,fields:{name,disabled:true,comment:marker}})
await execute("mm_vrf_create_plan",{serverId,fields:{name:vrf,interfaces:bridges.join(","),comment:marker}})
ownedTableId=String((await readRosSnapshot(serverId,"routing_table")).rows.find(row=>row.name===vrf)?.[".id"]??"")
assert.ok(ownedTableId)
await execute("mm_ospf_instance_create_plan",{serverId,fields:{name:instance,version:"2","router-id":`198.19.253.${serverId}`,vrf,"routing-table":vrf,disabled:false,comment:marker}})
await execute("mm_ospf_area_create_plan",{serverId,fields:{name:area,instance,"area-id":"0.0.0.0",type:"default",disabled:false,comment:marker}})
for(const[index,name]of bridges.entries())await execute("mm_ospf_template_create_plan",{serverId,fields:{interfaces:name,area,cost:30+index*10,passive:true,disabled:false,comment:marker}})
const current=async()=>{
const rows=(await readRosSnapshot(serverId,"ospf_template")).rows
assert.equal(rows.length,2);assert.ok(rows.every(row=>row.comment===marker&&bridges.includes(String(row.interfaces))&&row.area===area&&row.passive==="true"&&row.disabled==="false"))
return rows.sort((a,b)=>String(a.interfaces).localeCompare(String(b.interfaces)))
}
assert.deepEqual((await current()).map(row=>Number(row.cost)),[30,40])
const beforePatches=costPatches.length
const optimized=await execute("mm_ospf_application_optimize_plan",{serverId,pingWeight:60})
assert.deepEqual((await current()).map(row=>Number(row.cost)),[10,20]);assert.equal(costPatches.length,beforePatches+2)
const result=(await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1",[optimized])).rows[0].result
assert.equal(result.nativeWrites,2);assert.equal(result.forwardingVerified,false)
const observation=await invokeMcpTool(principal,"mm_operation_reconcile",{operationId:optimized})as{results:{matchesDesiredState:boolean}[]}
assert.equal(observation.results[0].matchesDesiredState,true);assert.equal(costPatches.length,beforePatches+2)
await execute("mm_ospf_application_restore_plan",{operationId:optimized})
assert.deepEqual((await current()).map(row=>Number(row.cost)),[30,40]);assert.equal(costPatches.length,beforePatches+4)
checks.push({phase:"legacy-apply-restore",serverId,initialCosts:[30,40],optimizedCosts:[10,20],restoredCosts:[30,40],costPatchDispatches:4,readonlyReconcile:true,approvedInverse:true,forwardingVerified:false});await save()
}finally{
if(activeOperation){checks.push({phase:"cleanup-refused-live-operation",serverId,operationId:activeOperation});await save();throw Error("Unfinished native operation; do not race cleanup")}
if(attempted){
for(const resource of ["ospf_template","ospf_area","ospf_instance","vrf","routing_table","bridge"]){
for(const row of (await readRosSnapshot(serverId,resource)).rows.filter(row=>resource==="routing_table"?row.name===vrf:row.comment===marker)){
if(resource==="ospf_template")assert.ok(bridges.includes(String(row.interfaces))&&row.area===area&&row.passive==="true")
if(resource==="ospf_area")assert.ok(row.name===area&&row.instance===instance)
if(resource==="ospf_instance")assert.ok(row.name===instance&&row.vrf===vrf)
if(resource==="vrf")assert.ok(row.name===vrf&&String(row.interfaces).split(",").sort().join(",")===bridges.join(","))
if(resource==="routing_table")assert.equal(row[".id"],ownedTableId)
if(resource==="bridge")assert.ok(bridges.includes(String(row.name))&&row.disabled==="true")
await execute(`mm_${resource}_delete_plan`,{serverId,id:row[".id"]})
}
}
for(const baseline of baselines.filter(row=>row.serverId===serverId))assert.equal((await readRosSnapshot(serverId,baseline.resource)).fingerprint,baseline.fingerprint,`Baseline changed: ${serverId}:${baseline.resource}`)
checks.push({phase:"baseline-restored",serverId,fingerprints:resources.length,ownedObjectsRemoved:true});await save()
}
}
}
assert.equal(costPatches.length,8)
for(const baseline of baselines)assert.equal((await readRosSnapshot(baseline.serverId,baseline.resource)).fingerprint,baseline.fingerprint)
report.complete=true;checks.push({phase:"all-baselines-restored",fingerprints:baselines.length,configurationOnly:true,originalIpv6ForwardingGateStillOpen:true});await save()
console.log("✓ Both CHRs: public legacy OSPF 30/40 → 10/20 → 30/40, eight cost PATCHes, approved restore, readonly reconcile and 24 baseline fingerprints restored; no forwarding claim")
}catch(error){
checks.push({phase:"failure",name:error instanceof Error?error.name:"unknown",code:error&&typeof error==="object"&&"code"in error?String(error.code).slice(0,80):"TEST_STAGE_UNCONFIRMED"})
process.exitCode=1
}finally{
Object.assign(report,{finishedAt:new Date().toISOString()});await save()
MikrotikClient.prototype.get=originalGet;MikrotikClient.prototype.post=originalPost;MikrotikClient.prototype.patch=originalPatch;MikrotikClient.prototype.put=originalPut;MikrotikClient.prototype.delete=originalDelete
await closePool()
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import { readRosSnapshot, routerClient } from "./resources.js"
/** No addresses or ports; disabled templates/bridge in a separate empty VRF. */
export async function acceptChrOspfPassive(marker: string, 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, loop = marker + "-passive-loop", vrf = marker + "-passive-vrf", resources = ["ospf_template", "ospf_area", "ospf_instance", "vrf", "routing_table", "bridge"]
const baselines = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
assert.ok(baselines.every(snapshot => !snapshot.rows.some(row => row.comment === marker || typeof row.name === "string" && row.name.startsWith(marker))))
checks.push({ phase: "passive-preflight", baselineFingerprints: baselines.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })) }); await checkpoint()
try {
await execute("mm_bridge_create_plan", { serverId, fields: { name: loop, disabled: true, comment: marker } })
await execute("mm_vrf_create_plan", { serverId, fields: { name: vrf, interfaces: loop, comment: marker } })
for (const version of ["2", "3"]) {
const instance = marker + "-passive-v" + version, area = marker + "-passive-area-v" + version
await execute("mm_ospf_instance_create_plan", { serverId, fields: { name: instance, version, "router-id": "198.19.254.254", vrf, "routing-table": vrf, disabled: false, comment: marker } })
await execute("mm_ospf_area_create_plan", { serverId, fields: { name: area, instance, "area-id": "0.0.0.0", type: "default", disabled: false, comment: marker } })
await execute("mm_ospf_template_create_plan", { serverId, fields: { interfaces: loop, area, cost: 30, passive: false, disabled: true, comment: marker } })
const own = () => readRosSnapshot(serverId, "ospf_template").then(snapshot => snapshot.rows.find(row => row.comment === marker && row.interfaces === loop && row.area === area)!)
let row = await own(); assert.equal(row.passive, "false")
await execute("mm_ospf_template_update_plan", { serverId, id: row[".id"], fields: { passive: true, cost: 40 } })
row = await own(); assert.equal(row.passive, "true"); assert.equal(row.cost, "40")
try {
const operationId = await execute("mm_ospf_template_update_plan", { serverId, id: row[".id"], fields: { passive: false, cost: 30 } })
row = await own(); assert.equal(row.passive, "false"); assert.equal(row.cost, "30")
await execute("mm_operation_recovery_plan", { operationId })
row = await own(); assert.equal(row.passive, "true"); assert.equal(row.cost, "40")
checks.push({ phase: "passive-switch-verified", version, createFalse: true, setTrue: true, unsetFalse: true, combinedFieldsSeparateIntents: true, inverseRestored: true }); await checkpoint()
} finally {
const { client } = await routerClient(serverId)
const native = (await client.get<Record<string, unknown>[]>("/routing/ospf/interface-template")).filter(item => item.comment === marker && item.interfaces === loop && item.area === area)
checks.push({ phase: "passive-native-readback", version, rows: native.map(item => ({ id: item[".id"], passivePresent: Object.hasOwn(item, "passive"), passive: item.passive ?? null, cost: item.cost, disabled: item.disabled })) }); await checkpoint()
}
}
} finally {
for (const resource of resources) {
for (const row of (await readRosSnapshot(serverId, resource)).rows.filter(row => row.comment === marker)) {
if (resource === "ospf_template") assert.ok(row.interfaces === loop && ["2", "3"].some(version => row.area === marker + "-passive-area-v" + version))
if (resource === "ospf_area") assert.ok(["2", "3"].some(version => row.instance === marker + "-passive-v" + version))
if (resource === "ospf_instance") assert.ok(row.vrf === vrf && ["2", "3"].some(version => row.name === marker + "-passive-v" + version))
if (resource === "vrf") assert.ok(row.name === vrf && String(row.interfaces) === loop)
if (resource === "routing_table") assert.equal(row.name, vrf)
if (resource === "bridge") assert.ok(row.name === loop && row.disabled === "true")
await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
}
}
for (const baseline of baselines) assert.equal((await readRosSnapshot(serverId, baseline.resource)).fingerprint, baseline.fingerprint)
checks.push({ phase: "passive-baseline-restored", fingerprints: baselines.length, noAddressesOrPortsCreated: true }); await checkpoint()
}
}
@@ -0,0 +1,176 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { invokeMcpTool } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { McpPrincipal } from "./store.js"
import { acceptChrOspf } from "./chr-ospf-acceptance.js"
import { acceptChrEcmp } from "./chr-ecmp-acceptance.js"
import { acceptChrBgp } from "./chr-bgp-acceptance.js"
import { chrFixtureVrf, chrOspfForwarding } from "./chr-fixture-context.js"
import { waitForChrOverlayReadiness,readChrIpv6ReadinessContext,type ChrOverlayProof } from "./chr-overlay-readiness.js"
type Execute = (tool: string, args: Record<string, unknown>) => Promise<string>
export async function cleanupChrOverlay(hosts: string[], marker: string, execute: Execute) {
assert.match(marker, /^mcp-test-[0-9a-f]{8}$/)
const gre = marker + "-gre", loop = marker + "-bgp-loop", errors: unknown[] = []
for (const [index] of hosts.entries()) {
const serverId = index + 1, vrf = chrFixtureVrf(marker, serverId)
try {
for (const resource of ["ipv6_address", "ipv4_address", "vrf", "gre", "bridge"]) {
const rows = (await readRosSnapshot(serverId, resource)).rows
const own = rows.filter(row => row.comment === marker && (resource.endsWith("address") ? [gre, loop].includes(String(row.interface)) : row.name === (resource === "vrf" ? vrf : resource === "bridge" ? loop : gre)))
for (const row of own) {
if (resource.endsWith("address")) assert.ok((row.interface === gre ? [`198.18.${Number.parseInt(marker.slice(-2), 16)}.${serverId}/30`, `fd6d:6370:${Number.parseInt(marker.slice(-4), 16).toString(16)}:1::${serverId}/64`] : [`198.19.${Number.parseInt(marker.slice(-2), 16)}.${serverId}/32`, `fd6d:6370:${Number.parseInt(marker.slice(-4), 16).toString(16)}:2::${serverId}/128`]).includes(String(row.address)), "Cleanup refuses an unexpected address")
if (resource === "vrf") assert.ok([gre, [gre, loop].sort().join(",")].includes(String(row.interfaces).split(",").sort().join(",")), "Cleanup refuses a VRF with foreign members")
if (resource === "gre") { assert.equal(row["local-address"], hosts[index]); assert.equal(row["remote-address"], hosts[1 - index]) }
await execute(`mm_${resource}_delete_plan`, { serverId, id: row[".id"] })
}
}
} catch (error) { errors.push(error) }
}
if (errors.length) throw new AggregateError(errors, "Owned overlay cleanup incomplete; inspect the recorded marker before further writes")
}
/** Mutates only a unique owned GRE/VRF fixture on the caller's allowlisted CHRs. */
export async function acceptChrOverlay(hosts: string[], marker: string, principal: McpPrincipal, execute: Execute, checks: Record<string, unknown>[], checkpoint: () => Promise<void>) {
assert.equal(hosts.length, 2, "Overlay acceptance requires both allowlisted CHRs")
const gre = marker + "-gre", loop = marker + "-bgp-loop"
const bgpExport = process.env.MMAPP_TEST_CHR_BGP_EXPORT === "true"
const ownedLoop = bgpExport || chrOspfForwarding()
const subnet = Number.parseInt(marker.slice(-2), 16)
const hextet = Number.parseInt(marker.slice(-4), 16).toString(16)
const ipv4 = [`198.18.${subnet}.1`, `198.18.${subnet}.2`]
const ipv6 = [`fd6d:6370:${hextet}:1::1`, `fd6d:6370:${hextet}:1::2`]
const resources = ["gre", "vrf", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route", "routing_table"]
if (process.env.MMAPP_TEST_CHR_BGP === "true") resources.push("bgp_instance", "bgp_connection")
if (ownedLoop) resources.push("bridge")
if (process.env.MMAPP_TEST_CHR_BGP_FILTER_BINDING === "true") resources.push("routing_filter")
const baseline = []
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, vrf = chrFixtureVrf(marker, serverId), { client } = await routerClient(serverId)
for (const resource of resources) baseline.push(await readRosSnapshot(serverId, resource))
const rows = baseline.filter(snapshot => snapshot.serverId === serverId).flatMap(snapshot => snapshot.rows)
assert.ok(!rows.some(row => row.name === gre || row.name === vrf || row.name === loop || row.comment === marker))
assert.ok(!rows.some(row => row["local-address"] === host && row["remote-address"] === hosts[1 - index]), "Existing GRE endpoints must not be reused")
assert.ok(!rows.some(row => String(row.address ?? "").startsWith(`198.18.${subnet}.`) || ownedLoop && String(row.address ?? "").startsWith(`198.19.${subnet}.`) || String(row.address ?? "").startsWith(`fd6d:6370:${hextet}:`)), "Fixture subnets already in use")
const wan = await client.get<Record<string, string>[]>("/ip/address")
assert.ok(wan.some(row => row.interface === "ether1" && row.address.split("/")[0] === host && row.disabled !== "true"))
const settings = await client.get<Record<string, string>>("/ipv6/settings")
assert.equal(settings["disable-ipv6"], "false", "IPv6 must already be enabled; acceptance never changes global settings")
}
checks.push({ overlayPreflight: true, baselineFingerprints: baseline.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })) })
await checkpoint() // Persist ownership and baseline before the first external write.
let attempted = false
try {
attempted = true
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, vrf = chrFixtureVrf(marker, serverId)
await execute("mm_gre_create_plan", { serverId, fields: { name: gre, "local-address": host, "remote-address": hosts[1 - index], disabled: true, comment: marker } })
if (ownedLoop) await execute("mm_bridge_create_plan", { serverId, fields: { name: loop, "protocol-mode": "none", comment: marker, ...(chrOspfForwarding() ? { disabled: true } : {}) } })
await execute("mm_vrf_create_plan", { serverId, fields: { name: vrf, interfaces: ownedLoop ? [gre, loop].sort().join(",") : gre, comment: marker } })
const ownVrf = (await readRosSnapshot(serverId, "vrf")).rows
assert.equal(ownVrf[0].name, vrf, "New VRF must precede main and existing VRFs")
if (ownedLoop) {
const { client } = await routerClient(serverId), native = (await client.get<Record<string, unknown>[]>("/ip/vrf")).find(row => row.name === vrf)
checks.push({ serverId, phase: "vrf-membership-readback", nativeMembers: native?.interfaces ?? null, canonicalMembers: ownVrf[0].interfaces }); await checkpoint()
}
await execute("mm_ipv4_address_create_plan", { serverId, fields: { address: ipv4[index] + "/30", interface: gre, comment: marker } })
await execute("mm_ipv6_address_create_plan", { serverId, fields: { address: ipv6[index] + "/64", interface: gre, advertise: false, comment: marker } })
if (ownedLoop) {
await execute("mm_ipv4_address_create_plan", { serverId, fields: { address: `198.19.${subnet}.${serverId}/32`, interface: loop, comment: marker } })
await execute("mm_ipv6_address_create_plan", { serverId, fields: { address: `fd6d:6370:${hextet}:2::${serverId}/128`, interface: loop, advertise: false, comment: marker } })
}
if (chrOspfForwarding()) {
const ownLoop = (await readRosSnapshot(serverId, "bridge")).rows.find(row => row.name === loop && row.comment === marker)!
assert.equal(ownLoop.disabled, "true")
await execute("mm_bridge_update_plan", { serverId, id: ownLoop[".id"], fields: { disabled: false } })
}
const ownGre = (await readRosSnapshot(serverId, "gre")).rows.find(row => row.name === gre && row.comment === marker)!
await assert.rejects(invokeMcpTool(principal, "mm_gre_delete_plan", { serverId, id: ownGre[".id"] }), error => error instanceof Error && (error as { statusCode?: number }).statusCode === 409 && /используется/i.test(error.message))
await execute("mm_gre_update_plan", { serverId, id: ownGre[".id"], fields: { disabled: false } })
console.log(`✓ ${host}: owned GRE/VRF and dual-stack addresses configured`)
}
if (chrOspfForwarding()) for (const serverId of [1, 2]) {
const { client } = await routerClient(serverId), vrf = chrFixtureVrf(marker, serverId)
const owned = (await client.get<Record<string, string>[]>("/ipv6/address")).filter(row => row.comment === marker && [gre, loop].includes(row.interface))
checks.push({ phase: "ospf-forward-address-context", serverId, vrf, addresses: owned.map(row => ({ id: row[".id"], interface: row.interface, address: row.address, vrf: row.vrf ?? null })) }); await checkpoint()
assert.equal(owned.length, 2)
assert.ok(owned.every(row => row.vrf === undefined || row.vrf === vrf), "Owned IPv6 address escaped its local VRF")
}
for (const serverId of [1, 2]) {
const vrf = chrFixtureVrf(marker, serverId), { client } = await routerClient(serverId)
for (let attempt = 0; attempt < 15; attempt++) {
const rows = await client.get<Record<string, string>[]>("/interface/gre")
if (rows.some(row => row.name === gre && row.running === "true")) break
assert.ok(attempt < 14, "GRE did not become running; no firewall or underlay changes are allowed")
await delay(1000)
}
// Connected addresses must resolve exclusively in the owned VRF, never main.
for (const resource of ["ipv4_route", "ipv6_route"]) {
const rows = (await readRosSnapshot(serverId, resource)).rows
const prefix = resource === "ipv4_route" ? `198.18.${subnet}.0/30` : `fd6d:6370:${hextet}:1::/64`
const connected = rows.filter(row => row["dst-address"] === prefix)
assert.ok(connected.length > 0 && connected.every(row => row["routing-table"] === vrf), "Connected overlay route escaped its VRF")
}
}
const verify = async (serverId: number, family: "ipv4" | "ipv6") => {
const vrf = chrFixtureVrf(marker, serverId), peerId = serverId === 1 ? 2 : 1, addresses = family === "ipv4" ? ipv4 : ipv6
const ownGre = (await readRosSnapshot(serverId, "gre")).rows.find(row => row.name === gre && row.comment === marker)!
const profile = { name: marker, serverId, community: "65000:65000", family, table: vrf, probeVrf: vrf, candidates: [{ id: "overlay", jumpHostId: peerId, exitNodeId: peerId, wanInterface: gre, downstreamInterface: gre, gateway: addresses[peerId - 1], gatewayRef: { resource: "gre", id: ownGre[".id"] }, controlProbe: { targetInterface: gre, targetAddress: addresses[peerId - 1], sourceAddress: addresses[serverId - 1], expectedHops: [{ serverId: peerId, interface: gre, address: addresses[peerId - 1] }] } }] }
return await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "overlay", proofPurpose: "control-path" }) as ChrOverlayProof
}
for (const serverId of [1, 2]) for (const family of ["ipv4", "ipv6"] as const) {
const {client}=await routerClient(serverId)
const proof = await waitForChrOverlayReadiness(async()=>{
if(family==="ipv6"){checks.push({phase:"overlay-ipv6-readiness-context",serverId,stage:"before-probe",context:await readChrIpv6ReadinessContext(client,marker,gre)});await checkpoint()}
const observed=await verify(serverId,family)
if(family==="ipv6"){checks.push({phase:"overlay-ipv6-readiness-context",serverId,stage:"after-probe",context:await readChrIpv6ReadinessContext(client,marker,gre)});await checkpoint()}
return observed
},async event=>{checks.push({phase:"overlay-readiness-probe",serverId,family,...event,configurationWritesRetried:false});await checkpoint()})
checks.push({ host: hosts[serverId - 1], family, phase: "overlay-up", proof })
await checkpoint()
assert.equal(proof.status, "passed", JSON.stringify(proof))
console.log(`✓ ${hosts[serverId - 1]}: ${family} GRE/VRF ping and traceroute`)
}
if (process.env.MMAPP_TEST_CHR_OSPF === "true") await acceptChrOspf(marker, principal, execute, checks, checkpoint)
if (process.env.MMAPP_TEST_CHR_ECMP === "true") await acceptChrEcmp(marker, principal, execute, checks, checkpoint)
if (process.env.MMAPP_TEST_CHR_BGP === "true") await acceptChrBgp(marker, principal, execute, checks, checkpoint)
const peerGre = (await readRosSnapshot(2, "gre")).rows.find(row => row.name === gre && row.comment === marker)!
assert.equal(peerGre.disabled, "false")
const faultOperation = await execute("mm_gre_update_plan", { serverId: 2, id: peerGre[".id"], fields: { disabled: true } })
for (const family of ["ipv4", "ipv6"] as const) {
const proof = await verify(1, family)
checks.push({ host: hosts[0], family, phase: "peer-disabled", proof })
await checkpoint()
assert.equal(proof.status, "failed", "A lab fault must have explicit loss evidence, not an unknown command result")
}
for (const serverId of [1, 2]) {
const { client } = await routerClient(serverId)
assert.ok((await client.get<Record<string, string>>("/system/resource")).version)
}
// GRE updates now use the shared service executor. Restore the known prior
// flag through a fresh human-approved service plan, not the ROS-only inverse.
const recoveryOperation = await execute("mm_gre_update_plan", { serverId: 2, id: peerGre[".id"], fields: { disabled: false } })
checks.push({ overlayRecovery: true, faultOperation, recoveryOperation, separateHumanPlan: true }); await checkpoint()
for (const family of ["ipv4", "ipv6"] as const) {
const proof = await verify(1, family)
checks.push({ host: hosts[0], family, phase: "recovered", proof })
await checkpoint()
assert.equal(proof.status, "passed", JSON.stringify(proof))
}
checks.push({ overlayTrafficVerified: true, explicitLinkFailureVerified: true, approvedInverseRecoveryVerified: true, managementSurvivedFault: true, threeNodeFailover: false })
} catch (error) {
checks.push({ overlayFailure: error instanceof Error ? { name: error.name, message: error.message.slice(0, 500), code: "code" in error ? error.code : undefined } : { name: "unknown" } })
await checkpoint()
throw error
} finally {
if (attempted) {
await cleanupChrOverlay(hosts, marker, execute)
for (const snapshot of baseline) assert.equal((await readRosSnapshot(snapshot.serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, `Overlay cleanup changed ${snapshot.serverId}:${snapshot.resource}`)
checks.push({ overlayBaselineRestored: true, resources, marker })
await checkpoint()
console.log("✓ Both CHRs: owned overlay removed, all configuration baselines restored")
}
}
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { waitForChrOverlayReadiness,readChrIpv6ReadinessContext,type ChrOverlayProof } from "./chr-overlay-readiness.js"
import { MikrotikError } from "../../services/mikrotik.js"
const good:ChrOverlayProof={status:"passed",packetCounts:{sent:3,received:3},trace:{verified:true,completeTrace:true}}
const partial:ChrOverlayProof={status:"unknown",reason:"PING_PARTIAL_LOSS",packetCounts:{sent:3,received:2}}
async function run(proofs:ChrOverlayProof[]){
let calls=0,waits=0;const recorded:{attempt:number;consecutive:number;ready:boolean;proof:ChrOverlayProof}[]=[]
const pending=waitForChrOverlayReadiness(async()=>{calls++;return proofs[Math.min(calls-1,proofs.length-1)]},async event=>{recorded.push(event)},async()=>{waits++})
return {pending,recorded,counts:()=>({calls,waits})}
}
const stable=await run([good]);await stable.pending;assert.deepEqual(stable.counts(),{calls:2,waits:1})
const cold=await run([partial,good,good]);await cold.pending;assert.equal(cold.recorded[0].proof.status,"unknown");assert.equal(cold.recorded[0].ready,false);assert.deepEqual(cold.counts(),{calls:3,waits:2})
const intermittent=await run([good,partial,good,partial]);await assert.rejects(intermittent.pending,/four observations/);assert.deepEqual(intermittent.counts(),{calls:4,waits:3})
for(const bad of [{status:"failed",packetCounts:{sent:3,received:0}},{status:"unknown",reason:"COMMAND_UNCONFIRMED"},{...good,packetCounts:{sent:2,received:2}},{...good,trace:{verified:true,completeTrace:false}},{...partial,packetCounts:{sent:4,received:3}}]){
const one=await run([bad]);await assert.rejects(one.pending,/no retry/);assert.equal(one.counts().calls,1)
}
let failures=0
await assert.rejects(waitForChrOverlayReadiness(async()=>{failures++;throw Error("native read failed")},async()=>{},async()=>{}));assert.equal(failures,1)
const marker="mcp-test-1234abcd",gre=marker+"-gre",paths:string[]=[]
const client={get:async<T>(path:string):Promise<T>=>{paths.push(path);return(path==="/ipv6/address"?[{interface:gre,comment:marker,address:"fd00::1/64","auth-key":"NEVER_READINESS_KEY"},{interface:"foreign",comment:"foreign",address:"NEVER_FOREIGN"}]:[{interface:gre,address:"fd00::2",status:"incomplete","mac-address":"NEVER_MAC"},{interface:"foreign",address:"NEVER_FOREIGN"}])as T}}
const context=await readChrIpv6ReadinessContext(client,marker,gre)
assert.deepEqual(paths,["/ipv6/address","/ipv6/neighbor"]);assert.equal(context.addresses[0].invalid,null);assert.equal(context.neighbors![0].status,"incomplete")
assert.equal(context.causeOfPacketLossVerified,false);assert.ok(!JSON.stringify(context).includes("NEVER_"))
const unsupported=await readChrIpv6ReadinessContext({get:async<T>(path:string):Promise<T>=>{if(path==="/ipv6/neighbor")throw new MikrotikError(404,path,"unsupported");return[]as T}},marker,gre)
assert.equal(unsupported.neighborCacheObserved,false);assert.equal(unsupported.neighbors,null)
await assert.rejects(readChrIpv6ReadinessContext({get:async<T>():Promise<T>=>{throw new MikrotikError(401,"/ipv6/neighbor","refused")}},marker,gre))
await assert.rejects(readChrIpv6ReadinessContext(client,marker,"foreign"));assert.equal(paths.length,2)
console.log("CHR overlay readiness: two consecutive 3/3 complete traces, preserved initial partial, strict four-read bound, no retry for failure/malformed/unconfirmed/transport, scoped IPv6 diagnostics and unknown flags; no configuration writes")
@@ -0,0 +1,52 @@
import { setTimeout as delay } from "node:timers/promises"
import { MikrotikError, type MikrotikClient } from "../../services/mikrotik.js"
import { isIP } from "node:net"
import { observedBoolean } from "../../services/ospf-observed-values.js"
export interface ChrOverlayProof {
status:string;reason?:string;packetCounts?:{sent:number;received:number};
trace?:{verified?:boolean;completeTrace?:boolean};[key:string]:unknown;
}
/** Fixture bring-up only. Repeats bounded observations, never a configuration mutation. */
export async function waitForChrOverlayReadiness<P extends ChrOverlayProof>(verify:()=>Promise<P>,record:(event:{attempt:number;consecutive:number;ready:boolean;proof:P})=>Promise<void>,wait:()=>Promise<unknown>=()=>delay(1000)){
let consecutive=0
for(let attempt=1;attempt<=4;attempt++){
const proof=await verify()
const passed=proof.status==="passed"&&proof.packetCounts?.sent===3&&proof.packetCounts.received===3&&proof.trace?.verified===true&&proof.trace.completeTrace===true
const startupPartial=proof.status==="unknown"&&proof.reason==="PING_PARTIAL_LOSS"&&proof.packetCounts?.sent===3&&[1,2].includes(proof.packetCounts.received)
consecutive=passed?consecutive+1:0
const ready=consecutive===2
await record({attempt,consecutive,ready,proof})
if(ready)return proof
if(!passed&&!startupPartial)throw Error("Overlay readiness has an unconfirmed/failed proof; no retry is allowed")
if(attempt<4)await wait()
}
throw Error("Overlay did not confirm two consecutive 3/3 complete paths within four observations")
}
/** Diagnostic projection of /ipv6/neighbor, documented by MikroTik IPv6 Neighbor Discovery.
* Missing flags stay null. Cache observations do not attest a cause of packet loss. */
export async function readChrIpv6ReadinessContext(client:Pick<MikrotikClient,"get">,marker:string,gre:string){
if(!/^mcp-test-[a-f0-9]{8}$/.test(marker)||gre!==marker+"-gre")throw Error("Unowned readiness interface")
const read=async(path:string)=>{
const rows=await client.get<unknown>(path)
if(!Array.isArray(rows)||rows.length>2000||Buffer.byteLength(JSON.stringify(rows))>1048576||rows.some(row=>!row||typeof row!=="object"||Array.isArray(row)))throw Error("Unconfirmed bounded IPv6 readiness catalog")
return rows as Record<string,unknown>[]
}
const addresses=(await read("/ipv6/address")).filter(row=>row.comment===marker&&row.interface===gre)
let neighbors:Record<string,unknown>[]|null=null
try{neighbors=(await read("/ipv6/neighbor")).filter(row=>row.interface===gre)}catch(error){if(!(error instanceof MikrotikError)||error.statusCode!==404)throw error}
const project=(rows:Record<string,unknown>[],fields:string[])=>rows.map(row=>Object.fromEntries(fields.map(key=>{
const value=row[key]
if(value===undefined||value===null)return[key,null]
if(["disabled","invalid","tentative","router"].includes(key))return[key,typeof value==="boolean"?value:typeof value==="string"?observedBoolean(value):null]
if(typeof value!=="string"||value.length>200||/[\u0000-\u001f\u007f]/.test(value))throw Error("Unconfirmed IPv6 context field")
if(key==="address"&&isIP(value.split("/")[0].split("%")[0])!==6)throw Error("Unconfirmed IPv6 context address")
if(key===".id"&&!/^\*[a-z0-9]+$/i.test(value))throw Error("Unconfirmed IPv6 context ID")
if(key==="status"&&!['noarp','incomplete','stale','reachable','delay','probe','failed','tentative','preferred','deprecated','invalid'].includes(value))return[key,null]
if(["vrf","VRF"].includes(key)&&value!=="main"&&!value.startsWith(marker+"-"))return[key,null]
return[key,value]
})))
return {observedAt:new Date().toISOString(),interface:gre,addresses:project(addresses,[".id","interface","address","vrf","disabled","invalid","tentative","status"]),
neighborCacheObserved:neighbors!==null,neighbors:neighbors===null?null:project(neighbors,[".id","interface","address","status","router","vrf","VRF"]),causeOfPacketLossVerified:false}
}
@@ -0,0 +1,134 @@
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import { preflightPaths } from "./ecmp-preflight.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === hosts.length && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const actor: AuthUser = { id: "chr-readonly-acceptance-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin", "mm:filters:admin", "mm:certificates:admin", "mm:bgp:admin", "mm:uptime:read", "mm:traffic:read"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const underlayDiagnostic = process.env.MMAPP_TEST_CHR_UNDERLAY === "true"
const report = { startedAt: new Date().toISOString(), complete: false, deviceMutations: 0, networkValidation: "not-performed", allUnderlayPacketsReceived: null as boolean | null, checks, finishedAt: "" }
const save = () => writeFile(new URL(underlayDiagnostic ? "../../../../docs/mcp-chr-underlay-diagnostic.json" : "../../../../docs/mcp-chr-catalog-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await save()
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, "CHR readonly acceptance", ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = ["routing.ospf.application.preview", "routing.filters.catalog.read", "routing.recursive.catalog.read", "routing.recursive.gateways.catalog.read", "routing.ecmp.preflight.read", "routing.gre.catalog.read", "containers.state.read", "certificates.state.read", "servers.snapshots.read", "servers.source-address.read", "servers.wan-runtime.read", "routing.bgp.sessions.read", "routing.bgp.sessions.raw.read", "uptime.ping.interfaces.read", "uptime.speed.interfaces.read", "traffic.live.read", "routing.ospf.state.read", "routing.bfd.sessions.read", "firewall.state.read", "network.wireguard.state.read", "network.ipsec.state.read", "network.vxlan.state.read", "network.gre.state.read"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "CHR read-only catalogs", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:servers:read", "mm:filters:read", "mm:certificates:read", "mm:bgp:read", "mm:uptime:read", "mm:traffic:read"], scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const resources = ["gre", "vrf", "routing_table", "ipv4_route", "ipv6_route", "ip_settings", "ipv6_settings", ...["ip", "ipv6"].flatMap(family => ["filter", "nat", "mangle", "raw"].map(table => `${family}_${table}`))]
const baseline = []
for (const resource of resources) baseline.push(await readRosSnapshot(serverId, resource))
const resource = await client.get<Record<string, unknown>>("/system/resource")
if (underlayDiagnostic) {
const destination = hosts[1 - index]
const fields = new Set(["seq", "sent", "received", "packet-loss", "status", "time", "avg-rtt", "min-rtt", "max-rtt"])
const settings = await client.get<Record<string, unknown>>("/ip/settings")
for (let round = 1; round <= 3; round++) {
const result = await client.post<Record<string, unknown>[]>("/tool/ping", { address: destination, count: "3", interval: "1s", "src-address": host }, 20000)
assert.ok(Array.isArray(result) && result.length > 0 && result.length <= 10)
const packets = result.map(row => Object.fromEntries(Object.entries(row).filter(([key]) => fields.has(key))))
const last = packets.at(-1)!, allReceived = String(last.sent) === "3" && String(last.received) === "3" && String(last["packet-loss"]) === "0"
report.allUnderlayPacketsReceived = (report.allUnderlayPacketsReceived ?? true) && allReceived
checks.push({ host, destination, phase: "underlay-ping", round, interval: "1s", packets, allReceived, routerOs: String(resource.version), rpFilter: settings["rp-filter"] ?? null, ipForward: settings["ip-forward"] ?? null })
await save()
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Diagnostic changed configuration or observed external drift")
checks.push({ host, phase: "underlay-configuration-check", configurationUnchanged: true })
report.networkValidation = "underlay ICMP only; no GRE/BGP or end-user forwarding claim"
await save(); continue
}
const catalogs = []
for (const tool of ["mm_ospf_application_preview", "mm_bgp_filter_rules_get", "mm_recursive_routes_catalog_get", "mm_recursive_gateways_catalog_get", "mm_gre_catalog_get", "mm_containers_state_get", "mm_certificates_state_get", "mm_server_snapshots_get", "mm_server_source_address_get", "mm_server_wan_runtime_get", "mm_bgp_sessions_get", "mm_bgp_sessions_raw_get"]) {
const result = await invokeMcpTool(principal, tool, { serverId,...(tool==="mm_ospf_application_preview"?{peerServerIds:[serverId]}:{}) }) as Record<string, unknown>
if(tool==="mm_ospf_application_preview"){
assert.equal(result.proposalOnly,true);assert.equal(result.executionAvailable,false);assert.equal(result.metricContextVerified,false);assert.equal(result.forwardingVerified,false)
assert.deepEqual(result.allowedMetricServerIds,[serverId]);assert.ok(Array.isArray((result.interfaces as {items:unknown[]}).items))
}
if (tool === "mm_bgp_filter_rules_get") {
const rulesets = result.rulesets as { serverId: string; live: boolean; stale: boolean; error: string | null; nativeRules: { total: number } | null }[]
assert.equal(rulesets.length, 1); assert.equal(rulesets[0].serverId, String(serverId)); assert.equal(rulesets[0].live, true); assert.equal(rulesets[0].stale, false); assert.equal(rulesets[0].error, null)
assert.ok(rulesets[0].nativeRules); assert.equal(result.writesPerformed, false); assert.equal(result.forwardingVerified, false)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM filter_rules WHERE server_id=$1", [serverId])).rows[0].n, 0)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM config_revisions WHERE server_id=$1 AND section='filters'", [serverId])).rows[0].n, 0)
}
if (tool === "mm_recursive_routes_catalog_get") { assert.equal(result.live, true); assert.equal(result.stale, false) }
if (tool === "mm_gre_catalog_get") assert.deepEqual(result.failures, [])
if (tool === "mm_server_wan_runtime_get") { assert.ok(["confirmed", "ambiguous", "unknown", "none"].includes(String(result.defaultRouteStatus))); assert.equal(result.forwardingVerified, false) }
if (tool === "mm_bgp_sessions_get") assert.deepEqual(result.failures, [])
assert.ok(!JSON.stringify(result).includes(process.env.MMAPP_TEST_CHR_PASSWORD!), "Device password must not enter the acceptance report")
if (tool === "mm_containers_state_get") {
const states = result.servers as { serverId: number; availability: string; failures: unknown[] }[]
assert.equal(states.length, 1); assert.equal(states[0].serverId, serverId)
assert.notEqual(states[0].availability, "error"); assert.deepEqual(states[0].failures, [])
}
if (tool === "mm_certificates_state_get") {
const states = result.servers as { serverId: string; error: string | null }[]
assert.equal(states.length, 1); assert.equal(states[0].serverId, String(serverId)); assert.equal(states[0].error, null)
}
// Store bounded already-masked MCP DTOs, never credentials/raw RouterOS rows.
catalogs.push({ tool, result })
}
const preflights = []
for (const tool of ["mm_wireguard_state_get", "mm_ipsec_state_get", "mm_vxlan_state_get", "mm_gre_state_get"]) {
const result = await invokeMcpTool(principal, tool, { serverId }) as { failures: unknown[]; serverIds: number[] }
assert.deepEqual(result.failures, []); assert.deepEqual(result.serverIds, [serverId])
assert.ok(!JSON.stringify(result).includes(process.env.MMAPP_TEST_CHR_PASSWORD!))
catalogs.push({ tool, result })
}
const firewall = await invokeMcpTool(principal, "mm_firewall_state_get", { serverId, limit: 200 }) as { failures: unknown[]; serverIds: number[]; packetMatchingVerified: boolean }
assert.deepEqual(firewall.failures, []); assert.deepEqual(firewall.serverIds, [serverId]); assert.equal(firewall.packetMatchingVerified, false)
assert.ok(!JSON.stringify(firewall).includes(process.env.MMAPP_TEST_CHR_PASSWORD!))
catalogs.push({ tool: "mm_firewall_state_get", result: firewall })
for (const tool of ["mm_ospf_state_get", "mm_bfd_sessions_get"]) {
const result = await invokeMcpTool(principal, tool, { serverId }) as { servers: { serverId: number }[]; failures: unknown[]; incomplete: boolean }
assert.equal(result.servers.length, 1); assert.equal(result.servers[0].serverId, serverId)
assert.deepEqual(result.failures, []); assert.equal(result.incomplete, false)
assert.ok(!JSON.stringify(result).includes(process.env.MMAPP_TEST_CHR_PASSWORD!))
catalogs.push({ tool, result })
}
for (const tool of ["mm_ping_source_interfaces_get", "mm_speed_test_interfaces_get"]) {
const result = await invokeMcpTool(principal, tool, { serverId }) as { interfaces: { name: string }[] }
assert.ok(result.interfaces.length > 0, "CHR must confirm an eligible probe interface")
assert.ok(!JSON.stringify(result).includes(process.env.MMAPP_TEST_CHR_PASSWORD!))
catalogs.push({ tool, result })
if (tool === "mm_ping_source_interfaces_get") {
const live = await invokeMcpTool(principal, "mm_traffic_live_get", { serverId, interface: result.interfaces[0].name }) as { samples: { rxMbps: number; txMbps: number }[]; forwardingVerified: boolean }
assert.equal(live.samples.length, 1); assert.equal(live.forwardingVerified, false)
assert.ok([live.samples[0].rxMbps, live.samples[0].txMbps].every(value => Number.isFinite(value) && value >= 0))
catalogs.push({ tool: "mm_traffic_live_get", result: live })
}
}
for (const family of ["ipv4", "ipv6"] as const) {
const raw = await client.get<Record<string, unknown>[]>(preflightPaths(family).routes)
const routeIds = raw.slice(0, 2).map(row => String(row[".id"]))
while (routeIds.length < 2) routeIds.push("*unobserved-" + routeIds.length)
const result = await invokeMcpTool(principal, "mm_ecmp_preflight_get", { serverId, family, routeIds }) as { sources: Record<string, { observed: boolean }>; networkValidation: string; rpf: { supported: boolean } }
assert.equal(result.networkValidation, "not-performed"); assert.equal(result.rpf.supported, family === "ipv4")
assert.ok(Object.values(result.sources).every(source => source.observed), "CHR did not confirm every preflight source")
preflights.push(result)
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Read-only acceptance changed configuration or observed external drift")
checks.push({ host, routerOs: String(resource.version), catalogs, preflights, baselineRestored: true, baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })) })
await save(); console.log(`✓ CHR ${serverId}: scoped catalogs and IPv4/IPv6 ECMP preflight; device configuration unchanged`)
}
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE action LIKE 'configuration.%'")).rows[0].n, 0)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operations")).rows[0].n, 0)
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import { readChrRecoverySource } from "./chr-recovery-source.js"
const marker = "mcp-test-1234abcd"
const rows = (resources: string[]) => [1, 2].flatMap(serverId => resources.map(resource => ({ serverId, resource, fingerprint: "a".repeat(64) })))
const overlay = rows(["gre", "vrf", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route", "routing_table"])
const ospf = rows(["ospf_template", "ospf_area", "ospf_instance", "bfd_configuration"])
const source = { marker, complete: false, finishedAt: new Date(Date.now() - 1000).toISOString(), optimizerRevision: 6, measurementRevision: 2, sourceMigration: "0029_mcp_routing_sources", checks: [{ overlayPreflight: true, baselineFingerprints: overlay }] }
assert.equal(readChrRecoverySource(source, marker).kind, "overlay")
const full = { ...source, checks: [...source.checks, { ospfPreflight: true, baselineFingerprints: ospf }, { tool: "mm_ospf_template_create_plan", state: "unconfirmed" }] }
assert.equal(readChrRecoverySource(full, marker).kind, "ospf")
assert.equal(readChrRecoverySource({ ...full, optimizerRevision: 7 }, marker).kind, "ospf")
assert.equal(readChrRecoverySource(full, marker).baselines.length, 22)
const forward = { ...full, ospfForwarding: true, fixtureSchema: "ospf-forward-v1", checks: [{ overlayPreflight: true, baselineFingerprints: [...overlay, ...rows(["bridge"])] }, full.checks[1]] }
assert.equal(readChrRecoverySource(forward, marker).baselines.length, 24)
assert.equal(readChrRecoverySource(forward, marker).ospfForwarding, true)
assert.equal(readChrRecoverySource(source, marker).ospfForwarding, false)
assert.throws(() => readChrRecoverySource({ ...forward, fixtureSchema: undefined }, marker))
assert.throws(() => readChrRecoverySource({ ...forward, ospfForwarding: false }, marker))
assert.throws(() => readChrRecoverySource({ ...full, ospfForwarding: true, fixtureSchema: "ospf-forward-v1" }, marker))
assert.throws(() => readChrRecoverySource({ ...full, checks: forward.checks }, marker))
for (const bad of [
{ ...full, complete: true }, { ...full, finishedAt: undefined }, { ...full, finishedAt: new Date(Date.now() + 100000).toISOString() },
{ ...full, marker: "mcp-test-ffffffff" }, { ...full, optimizerRevision: 8 }, { ...full, optimizerRevision: "6" }, { ...full, measurementRevision: 1 },
{ ...full, checks: [...source.checks, { ospfPreflight: true, baselineFingerprints: ospf.slice(1) }] },
{ ...full, checks: [...source.checks, { ospfPreflight: true, baselineFingerprints: [...ospf.slice(1), ospf[1]] }] },
{ ...full, checks: [...source.checks, { tool: "mm_bfd_configuration_create_plan" }] },
{ ...full, checks: [...full.checks, full.checks[1]] },
]) assert.throws(() => readChrRecoverySource(bad, marker))
const legacy = { marker, complete: false, finishedAt: source.finishedAt, checks: [{ overlayPreflight: true, baselineFingerprints: [...overlay, ...rows(["bgp_instance", "bgp_connection", "bridge", "routing_filter"])] }] }
assert.equal(readChrRecoverySource(legacy, marker).kind, "bgp")
assert.equal(readChrRecoverySource(legacy, marker).baselines.length, 22)
console.log("CHR recovery source: terminal marker-bound OSPF/overlay/BGP receipts, exact scoped baselines and malformed reports rejected")
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { z } from "zod"
const overlay = ["gre", "vrf", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route", "routing_table"]
const ospf = ["ospf_template", "ospf_area", "ospf_instance", "bfd_configuration"]
const bgp = ["bgp_instance", "bgp_connection", "bridge", "routing_filter"]
const baselineSchema = z.array(z.object({ serverId: z.union([z.literal(1), z.literal(2)]), resource: z.string(), fingerprint: z.string().regex(/^[a-f0-9]{64}$/) }).strict()).max(24)
/** Validate a finalized marker-bound report. The launcher/operator must separately confirm its process is terminal before invoking cleanup. */
export function readChrRecoverySource(value: unknown, marker: string) {
const report = z.object({ marker: z.string(), complete: z.literal(false), finishedAt: z.string().datetime(), checks: z.array(z.record(z.string(), z.unknown())).max(10000), optimizerRevision: z.unknown().optional(), measurementRevision: z.unknown().optional(), sourceMigration: z.unknown().optional(), ospfForwarding: z.boolean().optional(), fixtureSchema: z.unknown().optional() }).passthrough().parse(value)
assert.match(marker, /^mcp-test-[0-9a-f]{8}$/); assert.equal(report.marker, marker)
assert.ok(Date.parse(report.finishedAt) <= Date.now(), "Recovery refuses a live or future report")
const read = (flag: string, resources: string[]) => {
const receipts = report.checks.filter(row => row[flag] === true)
assert.equal(receipts.length, 1, "Exactly one baseline receipt required")
const rows = baselineSchema.parse(receipts[0].baselineFingerprints)
assert.equal(rows.length, resources.length * 2)
assert.equal(new Set(rows.map(row => row.serverId + ":" + row.resource)).size, rows.length)
assert.ok(rows.every(row => resources.includes(row.resource)))
return rows
}
const source = report.optimizerRevision !== undefined || report.measurementRevision !== undefined || report.sourceMigration !== undefined
if (!source) {
assert.ok(!report.ospfForwarding && report.fixtureSchema === undefined)
return { kind: "bgp" as const, ospfForwarding: false, baselines: read("overlayPreflight", [...overlay, ...bgp]) }
}
assert.ok([3, 4, 5, 6, 7].includes(Number(report.optimizerRevision)) && typeof report.optimizerRevision === "number")
assert.equal(report.measurementRevision, 2); assert.equal(report.sourceMigration, "0029_mcp_routing_sources")
const ospfForwarding = report.ospfForwarding === true
if (ospfForwarding) assert.equal(report.fixtureSchema, "ospf-forward-v1")
else assert.equal(report.fixtureSchema, undefined)
const baselines = read("overlayPreflight", [...overlay, ...(ospfForwarding ? ["bridge"] : [])])
if (report.checks.some(row => row.ospfPreflight === true)) return { kind: "ospf" as const, ospfForwarding, baselines: [...baselines, ...read("ospfPreflight", ospf)] }
assert.ok(!report.checks.some(row => typeof row.tool === "string" && /^mm_(?:ospf_|bfd_)/.test(row.tool)), "OSPF writes without their own preflight receipt cannot authorize recovery")
return { kind: "overlay" as const, ospfForwarding, baselines }
}
@@ -0,0 +1,119 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import { parseRecursiveCatalog } from "../../services/recursive-route-catalog.js"
import { readRecursiveNativeRowsForServer, readRecursiveRoutesForServer } from "../../routes/recursive-routes.js"
import { fingerprintPayload } from "../../services/config-revisions.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === hosts.length && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8), tableName = marker + "-rec"
const actor: AuthUser = { id: "chr-recursive-restore-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, trafficValidation: false, checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-recursive-restore-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = ["routing.recursive.revision.restore", "routing.recursive.routes.replace", "routing.recursive.routes.read", "routing.recursive.gateways.read", "ros.routing_table.create", "ros.routing_table.delete", "ros.ipv4_route.create", "ros.ipv4_route.delete"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write", "mm:network:read"], limits: { callsPerMinute: 120, concurrentReads: 4, mutationsPerHour: 100, serversPerPlan: 2, objectsPerPlan: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string; changes: { before: { observed: { routingTable: string; comment: string }[] }; steps: { input: { routes: { routingTable: string; comment: string; disabled: boolean }[] } }[] } }
if (["mm_recursive_revision_restore_plan", "mm_recursive_routes_replace_plan"].includes(tool)) {
assert.equal(plan.changes.before.observed.length, 1, "Never auto-approve replacement of an unexpected owned set")
assert.ok(plan.changes.before.observed.every(row => row.routingTable === tableName && row.comment === marker))
assert.ok(plan.changes.steps[0].input.routes.every(row => row.routingTable === tableName && row.comment === marker && row.disabled))
}
await approvePlan(plan.planId, actor, true)
const operation = await applyPlan(principal, plan.planId, randomUUID())
for (let attempt = 0; attempt < 100; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
assert.equal(row.state, "applied", `${tool}: ${row.state}/${row.error_code}`)
return operation.id
}
await delay(300)
}
throw Error("Operation pending; never automatically replay lab writes")
}
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { server, client } = await routerClient(serverId)
const baseline = await Promise.all(["ipv4_route", "routing_table"].map(resource => readRosSnapshot(serverId, resource)))
assert.equal(parseRecursiveCatalog(await readRecursiveNativeRowsForServer(server)).length, 0, "Lab restore refuses existing owned recursive routes")
assert.ok(!baseline[1].rows.some(row => row.name === tableName))
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })) }); await save()
try {
await execute("mm_routing_table_create_plan", { serverId, fields: { name: tableName, fib: false } })
const table = (await readRosSnapshot(serverId, "routing_table")).rows.find(row => row.name === tableName)
assert.equal(table?.fib, "false")
await execute("mm_ipv4_route_create_plan", { serverId, fields: { "dst-address": `198.18.250.${serverId}/32`, gateway: "192.0.2.1", "routing-table": tableName, distance: 10, scope: 30, "target-scope": 10, "check-gateway": "none", disabled: true, comment: "MikrotikManager:recursive " + marker } })
const owned = parseRecursiveCatalog(await readRecursiveNativeRowsForServer(server))
assert.equal(owned.length, 1); assert.equal(owned[0].routingTable, tableName); assert.equal(owned[0].comment, marker)
const cachedBeforeRestore = await readRecursiveRoutesForServer(server)
assert.equal(cachedBeforeRestore.live, true); assert.equal(cachedBeforeRestore.routes.length, 1)
const logicalId = cachedBeforeRestore.routes[0].id
const payload = [{ ...owned[0], distance: 25, country: "lab-metadata" }], revisionId = randomUUID()
await pool.query("INSERT INTO config_revisions(id,server_id,section,source,fingerprint,payload) VALUES($1,$2,'recursive-routes','apply',$3,$4::jsonb)", [revisionId, serverId, fingerprintPayload(payload), JSON.stringify(payload)])
const restored = await execute("mm_recursive_revision_restore_plan", { serverId, revisionId })
const after = parseRecursiveCatalog(await readRecursiveNativeRowsForServer(server))
assert.equal(after.length, 1); assert.equal(after[0].distance, 25); assert.equal(after[0].disabled, true); assert.notEqual(after[0].id, owned[0].id)
assert.equal((await pool.query("SELECT country FROM recursive_routes WHERE server_id=$1", [serverId])).rows[0].country, "lab-metadata")
checks.push({ host, phase: "restore", operationId: restored, nativeDistance: 25, disabled: true, nativeIdRecreated: true, metadataVerified: true }); await save()
const binding = (await pool.query("INSERT INTO filter_rules(server_id,community,gateway_tunnel_id) VALUES($1,'65000:997',$2) RETURNING id", [serverId, "rec:" + logicalId])).rows[0].id
try {
for (let attempt = 0; attempt < 2; attempt++) {
const live = await readRecursiveRoutesForServer(server)
assert.equal(live.live, true); assert.equal(live.routes[0].id, logicalId); assert.equal(live.routes[0].country, "lab-metadata")
}
const metadataBefore = (await pool.query("SELECT * FROM recursive_routes WHERE server_id=$1", [serverId])).rows
const revisionsBefore = (await pool.query("SELECT * FROM config_revisions WHERE server_id=$1 ORDER BY id", [serverId])).rows
const observed = await invokeMcpTool(principal, "mm_recursive_routes_get", { serverId }) as { routes: { id: string }[]; live: boolean; forwardingVerified: boolean }
assert.equal(observed.live, true); assert.equal(observed.routes[0].id, logicalId); assert.equal(observed.forwardingVerified, false)
await invokeMcpTool(principal, "mm_recursive_gateways_get", { serverId, limit: 1 })
assert.deepEqual((await pool.query("SELECT * FROM recursive_routes WHERE server_id=$1", [serverId])).rows, metadataBefore)
assert.deepEqual((await pool.query("SELECT * FROM config_revisions WHERE server_id=$1 ORDER BY id", [serverId])).rows, revisionsBefore)
assert.equal((await pool.query("SELECT gateway_tunnel_id FROM filter_rules WHERE id=$1", [binding])).rows[0].gateway_tunnel_id, "rec:" + logicalId)
checks.push({ host, phase: "stable-identity-readers", stableLogicalId: true, bindingPreserved: true, observerHasNoMetadataWrites: true, forwardingVerified: false }); await save()
const edited = await execute("mm_recursive_routes_replace_plan", { serverId, routes: [{ ...payload[0], id: logicalId, distance: 30 }] })
const afterEdit = await readRecursiveRoutesForServer(server)
assert.equal(afterEdit.live, true); assert.equal(afterEdit.routes[0].id, logicalId); assert.equal(afterEdit.routes[0].distance, 30); assert.equal(afterEdit.routes[0].disabled, true)
assert.equal((await pool.query("SELECT gateway_tunnel_id FROM filter_rules WHERE id=$1", [binding])).rows[0].gateway_tunnel_id, "rec:" + logicalId)
checks.push({ host, phase: "full-set-edit", operationId: edited, nativeDistance: 30, disabled: true, stableLogicalId: true, bindingPreserved: true, perCommandReadBack: true }); await save()
} finally { await pool.query("DELETE FROM filter_rules WHERE id=$1", [binding]) }
const directCleared = await execute("mm_recursive_routes_replace_plan", { serverId, routes: [], source: "copy" })
assert.equal(parseRecursiveCatalog(await readRecursiveNativeRowsForServer(server)).length, 0)
checks.push({ host, phase: "full-set-empty", operationId: directCleared, ownedSetEmpty: true }); await save()
await execute("mm_ipv4_route_create_plan", { serverId, fields: { "dst-address": `198.18.250.${serverId}/32`, gateway: "192.0.2.1", "routing-table": tableName, distance: 10, scope: 30, "target-scope": 10, "check-gateway": "none", disabled: true, comment: "MikrotikManager:recursive " + marker } })
const emptyRevision = randomUUID()
await pool.query("INSERT INTO config_revisions(id,server_id,section,source,fingerprint,payload) VALUES($1,$2,'recursive-routes','apply',$3,'[]'::jsonb)", [emptyRevision, serverId, fingerprintPayload([])])
const cleared = await execute("mm_recursive_revision_restore_plan", { serverId, revisionId: emptyRevision })
assert.equal(parseRecursiveCatalog(await readRecursiveNativeRowsForServer(server)).length, 0)
checks.push({ host, phase: "empty-revision", operationId: cleared, ownedSetEmpty: true }); await save()
} finally {
const own = (await readRosSnapshot(serverId, "ipv4_route")).rows.filter(row => row["routing-table"] === tableName && row.comment === "MikrotikManager:recursive " + marker)
for (const row of own) await execute("mm_ipv4_route_delete_plan", { serverId, id: row[".id"] })
assert.ok(!(await client.get<Record<string, unknown>[]>("/routing/rule")).some(row => row.table === tableName))
const tables = (await readRosSnapshot(serverId, "routing_table")).rows.filter(row => row.name === tableName)
for (const row of tables) { assert.equal(row.fib, "false"); await execute("mm_routing_table_delete_plan", { serverId, id: row[".id"] }) }
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Lab baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log(`✓ CHR ${serverId}: disabled non-FIB recursive restore, native/cache/revision read-back, empty restore and baseline cleanup`)
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,69 @@
import assert from "node:assert/strict"
import { readFile, writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { MikrotikClient } from "../../services/mikrotik.js"
import { configurationRows, resourceById } from "./resources.js"
import { fingerprint } from "./security.js"
assert.equal(process.env.MMAPP_TEST_CHR_SOURCE_DIAGNOSTIC, "true")
assert.ok(process.stdin.isTTY, "Private terminal required")
const original = JSON.parse(await readFile(new URL("../../../../docs/mcp-chr-ospf-source-interrupted.json", import.meta.url), "utf8"))
assert.equal(original.marker, "mcp-test-c74d0d3c"); assert.equal(original.complete, false); assert.ok(original.finishedAt)
const baselines = original.checks.find((row: { overlayPreflight?: boolean }) => row.overlayPreflight).baselineFingerprints as { serverId: number; resource: string; fingerprint: string }[]
assert.equal(baselines.length, 14)
process.stdout.write("CHR password (hidden): ")
let secret = await new Promise<string>((resolve, reject) => {
let value = ""
const finish = (error?: Error) => { clearTimeout(timer); process.stdin.off("data", read); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write("\n"); if (error) reject(error); else resolve(value) }
const read = (chunk: Buffer) => { for (const c of chunk.toString()) { if (c === "\r" || c === "\n") { finish(); return } if (c === "\u0003") { finish(new Error("Cancelled")); return } if (c === "\b" || c === "\u007f") value = value.slice(0, -1); else value += c } }
const timer = setTimeout(() => finish(new Error("Password timeout")), 60000)
process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on("data", read)
})
const checks: Record<string, unknown>[] = [], report = { startedAt: new Date().toISOString(), originalMarker: original.marker, deviceMutations: 0, complete: false, allExact: false, checks }
try {
for (const serverId of [1, 2]) {
const client = new MikrotikClient({ host: "127.0.0.1", port: serverId === 1 ? 19081 : 19082, useSsl: false, verifySsl: true, username: "admin", password: secret })
const system = await client.get<Record<string, unknown>>("/system/resource")
checks.push({ serverId, phase: "system", version: system.version, uptime: system.uptime })
for (const baseline of baselines.filter(row => row.serverId === serverId)) {
await delay(500)
const spec = resourceById(baseline.resource), rows = configurationRows(spec, await client.get(spec.path))
const hash = fingerprint(rows), ownRemaining = rows.some(row => JSON.stringify(row).includes(original.marker))
const check: Record<string, unknown> = { serverId, resource: baseline.resource, expected: baseline.fingerprint, actual: hash, exact: hash === baseline.fingerprint, ownRemaining, rows: rows.length }
if (!check.exact) {
// Diagnose one generated native ID without changing any router value.
const dynamic = rows.flatMap((row, index) => [true, "true", "yes"].includes(row.dynamic as string | boolean) ? [index] : [])
if (dynamic.length === 1) {
const index = dynamic[0]
for (let n = 0; n < 8192; n++) {
const oldId = "*" + n.toString(16).toUpperCase(), candidate = rows.map((row, i) => i === index ? { ...row, ".id": oldId } : row).sort((a, b) => String(a[".id"]).localeCompare(String(b[".id"])))
if (fingerprint(candidate) === baseline.fingerprint) { check.onlyGeneratedIdChanged = { previous: oldId, current: rows[index][".id"] }; break }
}
}
if (!check.onlyGeneratedIdChanged && dynamic.length > 0 && dynamic.length <= 10 && dynamic.every(index => /^\*[a-f0-9]{1,16}$/i.test(String(rows[index][".id"])))) {
const indices = new Set(dynamic)
for (let offset = -8192; offset <= 8192; offset++) {
const candidate = rows.map((row, index) => {
if (!indices.has(index)) return row
const value = BigInt("0x" + String(row[".id"]).slice(1)) + BigInt(offset)
return { ...row, ".id": value >= 0n ? "*" + value.toString(16).toUpperCase() : "invalid" }
}).sort((a, b) => String(a[".id"]).localeCompare(String(b[".id"])))
if (fingerprint(candidate) === baseline.fingerprint) {
check.onlyGeneratedIdsChanged = { commonOffset: offset, count: dynamic.length }
break
}
}
}
if (baseline.resource.endsWith("_route")) check.routeIdentities = rows.map(row => ({ id: row[".id"], dynamic: row.dynamic, table: row["routing-table"] }))
if (baseline.resource === "ipv4_address") check.safeAddressRows = rows.map(row => ({ id: row[".id"], address: row.address, interface: row.interface, disabled: row.disabled, dynamic: row.dynamic, hasComment: row.comment !== undefined }))
}
checks.push(check)
await writeFile(new URL("../../../../docs/mcp-chr-ospf-source-baseline-diagnostic.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
}
}
report.allExact = checks.filter(row => row.resource).every(row => row.exact)
report.complete = true
} finally {
secret = ""
await writeFile(new URL("../../../../docs/mcp-chr-ospf-source-baseline-diagnostic.json", import.meta.url), JSON.stringify({ ...report, finishedAt: new Date().toISOString() }, null, 2) + "\n")
}
@@ -0,0 +1,87 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8)
const actor: AuthUser = { id: "chr-vpn-catalog-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, trafficValidation: false, checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-vpn-catalog-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
const definitions = [
{ resource: "gre", suffix: "gre", path: "/interface/gre", fields: { "local-address": "0.0.0.0", "remote-address": "198.18.255.254", mtu: 1400, keepalive: "10s,10" } },
{ resource: "wireguard", suffix: "wg", path: "/interface/wireguard", fields: { "listen-port": 13231, mtu: 1420 } },
{ resource: "vxlan", suffix: "vx", path: "/interface/vxlan", fields: { vni: 16770001, port: 4789 } },
{ resource: "ipsec_peer", suffix: "ike", path: "/ip/ipsec/peer", fields: { address: "198.18.255.254/32", "exchange-mode": "ike2", profile: "default", passive: true } },
] as const
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = [...definitions.flatMap(row => [`ros.${row.resource}.create`, `ros.${row.resource}.delete`]), "network.wireguard.state.read", "network.vxlan.state.read", "network.ipsec.state.read", "network.gre.state.read"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string }
await approvePlan(plan.planId, actor, true)
const operation = await applyPlan(principal, plan.planId, randomUUID())
for (let attempt = 0; attempt < 150; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) { assert.equal(row.state, "applied", `${tool}: ${row.state}/${row.error_code}`); return operation.id }
await delay(300)
}
throw Error("Operation pending; never replay lab writes automatically")
}
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const baseline = await Promise.all([...definitions.map(row => row.resource), "ipv4_address", "ipv6_address"].map(resource => readRosSnapshot(serverId, resource)))
assert.ok(baseline.slice(0, definitions.length).every(snapshot => snapshot.rows.length === 0), "Lab refuses existing GRE/WireGuard/VXLAN/IPsec peer configs")
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })) }); await save()
try {
for (const definition of definitions) await execute(`mm_${definition.resource}_create_plan`, { serverId, fields: { ...definition.fields, name: `${marker}-${definition.suffix}`, disabled: true, comment: marker } })
const wg = await invokeMcpTool(principal, "mm_wireguard_state_get", { serverId }) as { interfaces: { name: string; enabled: boolean | null }[]; failures: unknown[]; keyMaterialIncluded: boolean }
assert.deepEqual(wg.failures, []); assert.equal(wg.interfaces.length, 1); assert.equal(wg.interfaces[0].enabled, false); assert.equal(wg.keyMaterialIncluded, false)
const rawWg = await client.get<Record<string, string>[]>("/interface/wireguard"), key = rawWg.find(row => row.name === `${marker}-wg`)?.["private-key"]
if (key) assert.ok(!JSON.stringify(wg).includes(key), "Generated native private key must not enter MCP output")
const rawVx = await client.get<Record<string, string>[]>("/interface/vxlan")
checks.push({ host, phase: "native-vxlan-fields", rows: rawVx.map(row => Object.fromEntries([".id", "name", "l2mtu", "mtu", "arp", "arp-proxy", "mac-learning", "learning", "running", "disabled", "port"].map(field => [field, row[field]]))) }); await save()
const vx = await invokeMcpTool(principal, "mm_vxlan_state_get", { serverId }) as { tunnels: { name: string; enabled: boolean; l2mtu: number | null; dstPort: number }[]; failures: unknown[] }
assert.deepEqual(vx.failures, []); assert.equal(vx.tunnels.length, 1); assert.equal(vx.tunnels[0].enabled, false)
assert.equal(vx.tunnels[0].dstPort, 4789); assert.equal("port" in vx.tunnels[0], false)
if (rawVx[0].l2mtu === undefined) assert.equal(vx.tunnels[0].l2mtu, null)
const ike = await invokeMcpTool(principal, "mm_ipsec_state_get", { serverId }) as { servers: { peers: { name: string; disabled: boolean | null }[] }[]; failures: unknown[] }
assert.deepEqual(ike.failures, []); assert.equal(ike.servers[0].peers.length, 1); assert.equal(ike.servers[0].peers[0].disabled, true)
const gre = await invokeMcpTool(principal, "mm_gre_state_get", { serverId }) as { tunnels: { name: string; enabled: boolean | null; mtu: number | null; keepaliveInterval: number | null; keepaliveRetries: number | null; remoteAddress: string }[]; failures: unknown[]; forwardingVerified: boolean }
assert.deepEqual(gre.failures, []); assert.equal(gre.tunnels.length, 1); assert.equal(gre.tunnels[0].name, marker + "-gre")
assert.equal(gre.tunnels[0].enabled, false); assert.equal(gre.tunnels[0].mtu, 1400); assert.equal(gre.tunnels[0].remoteAddress, "198.18.255.254")
assert.equal(gre.tunnels[0].keepaliveInterval, 10); assert.equal(gre.tunnels[0].keepaliveRetries, 10); assert.equal(gre.forwardingVerified, false)
const dto = { wireguard: wg, vxlan: vx, ipsec: ike, gre }
assert.ok(!JSON.stringify(dto).includes(process.env.MMAPP_TEST_CHR_PASSWORD!))
if (key) assert.ok(!JSON.stringify((await pool.query("SELECT result FROM mcp_requests WHERE connection_id=$1", [access.connection.id])).rows).includes(key))
checks.push({ host, phase: "populated-disabled-catalogs", keyMaterialExcluded: true, result: dto }); await save()
} finally {
for (const definition of [...definitions].reverse()) for (const row of await client.get<Record<string, string>[]>(definition.path)) if (row.name === `${marker}-${definition.suffix}` && row.comment === marker) {
assert.equal(row.disabled, "true"); await execute(`mm_${definition.resource}_delete_plan`, { serverId, id: row[".id"] })
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Lab VPN baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log(`✓ CHR ${serverId}: disabled GRE/WG/VXLAN/IPsec peers, populated catalogs without private keys, complete cleanup`)
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }
@@ -0,0 +1,88 @@
import assert from "node:assert/strict"
import { pool } from "../../db/index.js"
import { readWireguardInterfaceUpdateState, planWireguardInterfaceUpdate, projectWireguardInterfaceUpdate } from "../../services/wireguard-interface-update.js"
import { wireguardInterfaceDeleteValue } from "../../services/wireguard-interface-delete.js"
import { wireguardConfigurationRows } from "../../services/wireguard-peer-delete.js"
import { greStableRows } from "../../services/gre-mutation-state.js"
import { fingerprint } from "./security.js"
import { wireguardCreateKey, wireguardPublicKey } from "../../services/wireguard-interface-create.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import { invokeMcpTool } from "./runtime.js"
import type { McpPrincipal } from "./store.js"
/** Owned disabled fixtures: native identity and basic PATCH, never forwarding. */
export async function acceptChrWireguardInterfaceUpdate(marker: string, principal: McpPrincipal, serverIds: number[], 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}$/)
for (const serverId of serverIds) {
const name = `${marker}-wg-patch-${serverId}`, renamed = name + "-renamed"
const { server, client } = await routerClient(serverId)
const baseline = await readWireguardInterfaceUpdateState(server, "*0")
const resources = ["wireguard", "wireguard_peer", "ipv4_address", "ipv6_address"]
const snapshots = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
assert.ok(snapshots.every(snapshot => !snapshot.rows.some(row => row.name === name || row.name === renamed || row.comment === marker)))
checks.push({ phase: "wg-interface-update-preflight", serverId, privateBaseline: baseline.proof, baselines: snapshots.map(({ resource, fingerprint }) => ({ resource, fingerprint })) }); await checkpoint()
let id: string | undefined
try {
await execute("mm_wireguard_create_plan", { serverId, fields: { name, disabled: true, comment: marker, mtu: 1420 } })
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"); id = String(own[0][".id"])
const generic = await client.get<Record<string, unknown>[]>("/interface?.proplist=.id,name,type,disabled", 10000, undefined, 1048576)
const match = generic.filter(row => row[".id"] === id || row.name === name)
assert.equal(match.length, 1); assert.equal(match[0][".id"], id); assert.equal(match[0].name, name); assert.equal(match[0].type, "wg")
checks.push({ phase: "wg-interface-nonempty-identity", serverId, nativeId: id, genericId: match[0][".id"], type: match[0].type, disabled: true }); await checkpoint()
const patch = { name: renamed, listenPort: 51901, mtu: 1400, comment: marker + "-updated", disabled: true }
const before = await readWireguardInterfaceUpdateState(server, id)
const commands = planWireguardInterfaceUpdate(before, id, patch)
assert.equal(commands.length, 1)
const expected = projectWireguardInterfaceUpdate(before, commands[0])
let operationId: string
try { operationId = await execute("mm_wireguard_interface_edit_plan", { serverId, interfaceId: id, patch }) }
catch (error) {
const after = await readWireguardInterfaceUpdateState(server, id)
const left = wireguardInterfaceDeleteValue(expected), right = wireguardInterfaceDeleteValue(after)
const sections = Object.keys(left).filter(key => fingerprint(left[key as keyof typeof left]) !== fingerprint(right[key as keyof typeof right]))
const differences: { collection: string; nativeId: string; fields: string[] }[] = []
const permittedFields = new Set(["name", "mtu", "listen-port", "disabled", "comment", "public-key", "private-key", "mac-address", "arp", "arp-timeout", "default-name", "type", "dynamic"])
const compare = (collection: string, a: Record<string, string>[], b: Record<string, string>[]) => {
const old = greStableRows(wireguardConfigurationRows(a)), fresh = greStableRows(wireguardConfigurationRows(b))
for (const nativeId of new Set([...old, ...fresh].map(row => row[".id"]))) {
if (typeof nativeId !== "string" || !/^\*[a-z0-9]+$/i.test(nativeId)) throw new Error("Invalid diagnostic native ID")
const first = old.find(row => row[".id"] === nativeId), second = fresh.find(row => row[".id"] === nativeId)
if (fingerprint(first ?? null) === fingerprint(second ?? null)) continue
const keys = [...new Set([...Object.keys(first ?? {}), ...Object.keys(second ?? {})])].filter(key => first?.[key] !== second?.[key])
differences.push({ collection, nativeId, fields: [...new Set(keys.map(key => permittedFields.has(key) ? key : "opaque-field"))] })
}
}
compare("wireguard", expected.base.interfaces, after.base.interfaces)
for (const path of Object.keys(expected.dependencies)) compare(path, expected.dependencies[path], after.dependencies[path])
const first = expected.base.interfaces.find(row => row[".id"] === id)!, second = after.base.interfaces.find(row => row[".id"] === id)!
const originalKey = wireguardCreateKey.safeParse(first?.["private-key"]), observedKey = wireguardCreateKey.safeParse(second?.["private-key"])
const clamp = (value: string) => { const bytes = Buffer.from(value, "base64"); bytes[0] &= 248; bytes[31] = (bytes[31] & 127) | 64; return bytes }
const keyFacts = { originalPresent: first?.["private-key"] !== undefined, observedPresent: second?.["private-key"] !== undefined, originalValid: originalKey.success, observedValid: observedKey.success, publicIdentityUnchanged: first?.["public-key"] === second?.["public-key"], originalDerivesPublic: originalKey.success && wireguardPublicKey(originalKey.data) === first?.["public-key"], observedDerivesPublic: observedKey.success && wireguardPublicKey(observedKey.data) === second?.["public-key"], clampedScalarsEqual: originalKey.success && observedKey.success && clamp(originalKey.data).equals(clamp(observedKey.data)) }
checks.push({ phase: "wg-interface-update-projection-diagnostic", serverId, sections, differences: differences.slice(0, 100), keyFacts, sourceProofUnchanged: expected.base.serverProof === after.base.serverProof, rawValuesDisclosed: false }); await checkpoint()
throw error
}
let current = (await readRosSnapshot(serverId, "wireguard")).rows.find(row => row[".id"] === id)
assert.ok(current); assert.equal(current.name, renamed); assert.equal(current.comment, marker + "-updated"); assert.equal(Number(current["listen-port"]), 51901); assert.equal(Number(current.mtu), 1400); assert.equal(current.disabled, "true")
const result = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=0 AND state='verified'", [operationId])).rows[0]?.result
assert.equal(result?.verified, true); assert.equal(result.commandCount, 1); assert.equal(result.completedCommands, 1)
const reconciled = await invokeMcpTool(principal, "mm_operation_reconcile", { operationId }) as { automaticRetry: boolean; results: { matchesDesiredState: boolean }[] }
assert.equal(reconciled.automaticRetry, false); assert.equal(reconciled.results[0]?.matchesDesiredState, true)
await execute("mm_wireguard_update_plan", { serverId, id, fields: { comment: marker, mtu: 1420, disabled: true } })
current = (await readRosSnapshot(serverId, "wireguard")).rows.find(row => row[".id"] === id)
assert.equal(current?.comment, marker); assert.equal(Number(current?.mtu), 1420); assert.equal(current?.name, renamed); assert.equal(current?.disabled, "true")
checks.push({ phase: "wg-interface-update-verified", serverId, operationId, nativeId: id, businessAndNativeAlias: true, fields: ["name", "listenPort", "mtu", "comment", "disabled"], exactVerifiedReconcile: true, fixtureRemainedDisabled: true, forwardingVerified: false, keyRotationVerified: false, addressAndPeerRenameVerified: false }); await checkpoint()
} finally {
// A distinct approved cleanup plan observes current state; never resend an unknown PATCH.
const rows = (await readRosSnapshot(serverId, "wireguard")).rows.filter(row => [name, renamed].includes(String(row.name)) && [marker, marker + "-updated"].includes(String(row.comment)))
assert.ok(rows.length <= 1)
for (const row of rows) {
assert.equal(row.disabled, "true"); if (id !== undefined) assert.equal(row[".id"], id)
await execute("mm_wireguard_delete_plan", { serverId, id: row[".id"] })
}
for (const snapshot of snapshots) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint)
assert.equal((await readWireguardInterfaceUpdateState(server, "*0")).proof, baseline.proof)
checks.push({ phase: "wg-interface-update-baseline-restored", serverId, fingerprints: snapshots.length, privateProofRestored: true, unknownForwardNotReplayed: true, forwardingVerified: false }); await checkpoint()
}
}
}
@@ -0,0 +1,54 @@
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()
}
}
@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import { pool } from "../../db/index.js"
import { generateWireGuardKeyPair } from "../../services/wg-keys.js"
import { readWireguardPeerDeletionState } from "../../services/wireguard-peer-delete.js"
import { projectWireguardPeerUpdate, wireguardPeerNativeUpdate, wireguardPeerUpdateDifferences, observedWireguardPeerKeepalive } from "../../services/wireguard-peer-update.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import { findAction } from "./actions.js"
import type { McpPrincipal } from "./store.js"
import { invokeMcpTool } from "./runtime.js"
/** A disabled, addressless interface and disabled peer never participate in traffic. */
export async function acceptChrWireguardPeer(marker: string, principal: McpPrincipal, execute: (tool: string, args: Record<string, unknown>) => Promise<string>, checks: Record<string, unknown>[], checkpoint: () => Promise<void>, updatePeer = false) {
assert.match(marker, /^mcp-test-[a-f0-9]{8}$/)
const serverId = 1, name = marker + "-wg", { publicKey } = generateWireGuardKeyPair()
const createInterface = { serverId, fields: { name, disabled: true, comment: marker } }
const createPeer = { serverId, fields: { interface: name, "public-key": publicKey, "allowed-address": "198.19.254.2/32", disabled: true, comment: marker } }
findAction("mm_wireguard_create_plan").schema.parse(createInterface)
findAction("mm_wireguard_peer_create_plan").schema.parse(createPeer)
findAction("mm_wireguard_peer_remove_plan").schema.parse({ serverId, peerId: "*1" })
const { server } = await routerClient(serverId), privateBaseline = await readWireguardPeerDeletionState(server)
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-peer-preflight", baselineFingerprints: baselines.map(({ serverId, resource, fingerprint }) => ({ serverId, resource, fingerprint })), privateProofBaseline: privateBaseline.proof }); await checkpoint()
let deletionStarted = false
const ownedPublicKeys = new Set([publicKey])
try {
await execute("mm_wireguard_create_plan", createInterface)
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")
await execute("mm_wireguard_peer_create_plan", createPeer)
const peers = (await readRosSnapshot(serverId, "wireguard_peer")).rows.filter(row => row.interface === name && row.comment === marker && row["public-key"] === publicKey)
assert.equal(peers.length, 1); assert.equal(peers[0].disabled, "true")
checks.push({ phase: "wg-owned-peer-created", interfaceName: name, peerId: peers[0][".id"], peerPublicKey: publicKey, interfaceDisabled: true, peerDisabled: true }); await checkpoint()
if (updatePeer) {
const peerId = String(peers[0][".id"]), replacementKey = generateWireGuardKeyPair().publicKey
ownedPublicKeys.add(replacementKey)
const patch = { publicKey: replacementKey, allowedAddresses: ["198.19.254.9/32", "2001:db8:ffff::9/128"], endpointAddress: "192.0.2.123", endpointPort: 13232, persistentKeepalive: 60, comment: marker, name: marker + "-peer", disabled: true, clientAddress: "198.19.254.9/32", clientDns: "192.0.2.53", clientEndpoint: "192.0.2.99" }
const updateOperationId = await execute("mm_wireguard_peer_edit_plan", { serverId, peerId, patch })
const reconciled = await invokeMcpTool(principal, "mm_operation_reconcile", { operationId: updateOperationId }) as { automaticRetry: boolean; results: { matchesDesiredState: boolean }[] }
assert.equal(reconciled.automaticRetry, false); assert.equal(reconciled.results[0].matchesDesiredState, true)
const clearFields = { "public-key": publicKey, "allowed-address": "198.19.254.2/32", "endpoint-address": "", "endpoint-port": 0, "persistent-keepalive": "0", comment: marker, disabled: true }
const clearDesired = projectWireguardPeerUpdate(await readWireguardPeerDeletionState(server), peerId, wireguardPeerNativeUpdate(clearFields))
let clearOperationId: string
try { clearOperationId = await execute("mm_wireguard_peer_update_plan", { serverId, id: peerId, fields: clearFields }) }
catch (error) {
try {
const actual = await readWireguardPeerDeletionState(server)
checks.push({ phase: "wg-peer-clear-difference", fields: wireguardPeerUpdateDifferences(clearDesired, actual, peerId), nativeValuesWithheld: true })
const selected = actual.peers.find(row => row[".id"] === peerId)!
if (selected?.interface === name && selected.comment === marker && ownedPublicKeys.has(selected["public-key"])) {
const { client } = await routerClient(serverId)
const getter = await client.post<unknown>("/interface/wireguard/peers/get", { ".id": peerId, "value-name": "persistent-keepalive" })
const ret = getter && typeof getter === "object" && "ret" in getter ? getter.ret : undefined
const present = await client.post<unknown>("/interface/wireguard/peers/print", { ".proplist": [".id"], ".query": ["persistent-keepalive"] })
const zero = await client.post<unknown>("/interface/wireguard/peers/print", { ".proplist": [".id"], ".query": ["persistent-keepalive=0"] })
const ids = (value: unknown) => Array.isArray(value) && value.every(row => row && typeof row === "object" && typeof row[".id"] === "string") ? value.map(row => row[".id"]) : null
checks.push({ phase: "wg-peer-keepalive-source", propertyPresent: Object.hasOwn(selected, "persistent-keepalive"), propertyEmpty: selected["persistent-keepalive"] === "", propertySeconds: observedWireguardPeerKeepalive(selected["persistent-keepalive"] ?? ""), getterShape: Array.isArray(getter) ? getter.length === 0 ? "empty-array" : "array" : "object", getterEmpty: ret === "", getterSeconds: typeof ret === "string" ? observedWireguardPeerKeepalive(ret) : null, selectedInPresentQuery: ids(present)?.includes(peerId) ?? null, selectedInZeroQuery: ids(zero)?.includes(peerId) ?? null, nativeValuesWithheld: true })
}
} catch { checks.push({ phase: "wg-peer-clear-difference", sourceUnavailable: true, nativeValuesWithheld: true }) }
await checkpoint(); throw error
}
// Empty fields are genuine clears; the public service retains every other field.
const current = (await readWireguardPeerDeletionState(server)).peers.find(row => row[".id"] === peerId)!
assert.equal(current["public-key"], publicKey); assert.equal(current["endpoint-address"] ?? "", "")
const path = "/interface/wireguard/peers/" + encodeURIComponent(peerId)
const updateIntents = (await pool.query("SELECT count(*)::int AS n FROM mcp_audit WHERE connection_id=$1 AND action='configuration.intent' AND payload->>'method'='PATCH' AND payload->>'path'=$2", [principal.connectionId, path])).rows[0].n
assert.equal(updateIntents, 2)
checks.push({ phase: "wg-peer-update-verified", updateOperationId, clearOperationId, patchIntents: updateIntents, publicFieldsUpdated: Object.keys(patch).length, readOnlyReconciliation: true, parentAndForeignPeersPreserved: true, disabledInterfaceAndPeer: true, configuredAddressesUnchanged: true, forwardingVerified: false }); await checkpoint()
}
deletionStarted = true
const operationId = await execute("mm_wireguard_peer_remove_plan", { serverId, peerId: peers[0][".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)
const reconciliation = await invokeMcpTool(principal, "mm_operation_reconcile", { operationId }) as { automaticRetry: boolean; results: { matchesDesiredState: boolean; matchesOriginalState: boolean }[] }
assert.equal(reconciliation.automaticRetry, false); assert.equal(reconciliation.results.length, 1)
assert.equal(reconciliation.results[0].matchesDesiredState, true); assert.equal(reconciliation.results[0].matchesOriginalState, false)
const path = "/interface/wireguard/peers/" + encodeURIComponent(String(peers[0][".id"]))
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' AND payload->>'path'=$2", [principal.connectionId, path])).rows[0].n
assert.equal(intents, 1)
assert.ok(!(await readRosSnapshot(serverId, "wireguard_peer")).rows.some(row => row[".id"] === peers[0][".id"]))
assert.equal((await readRosSnapshot(serverId, "wireguard")).rows.find(row => row[".id"] === own[0][".id"])!.disabled, "true")
checks.push({ phase: "wg-peer-deletion-verified", operationId, deleteIntents: intents, nativeReadBack: true, readOnlyReconciliation: true, parentPreserved: true, revisionRecorded: receipt.revisionRecorded, forwardingVerified: false }); await checkpoint()
} finally {
const remaining = (await readRosSnapshot(serverId, "wireguard_peer")).rows.filter(row => row.comment === marker && row.interface === name && ownedPublicKeys.has(String(row["public-key"])))
const deletionTrace = checks.filter(row => row.phase === "mcp-operation" && row.tool === "mm_wireguard_peer_remove_plan").at(-1)
const mayCleanPeer = !deletionStarted || !deletionTrace?.operationId || deletionTrace.state === "rejected"
if (remaining.length && !mayCleanPeer) {
checks.push({ phase: "wg-peer-cleanup-unconfirmed", marker, ownedDisabledFixtureRemaining: true, uncertainDeleteNotReplayed: true }); await checkpoint()
throw new Error("Owned WireGuard fixture requires reconciliation; uncertain DELETE will not be repeated")
}
for (const row of remaining) await execute("mm_wireguard_peer_delete_plan", { serverId, id: row[".id"] })
assert.ok(!(await readRosSnapshot(serverId, "wireguard_peer")).rows.some(row => row.interface === name))
for (const row of (await readRosSnapshot(serverId, "wireguard")).rows.filter(row => row.name === name && row.comment === marker)) 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 readWireguardPeerDeletionState(server)).proof, privateBaseline.proof)
checks.push({ phase: "wg-peer-baseline-restored", fingerprints: baselines.length, privateProofRestored: true, configuredAddressesUnchanged: true, trafficVerified: false }); await checkpoint()
}
}
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod"
import { CONFIG_SECTIONS, CONFIG_REVISION_KEEP, listRevisions } from "../../services/config-revisions.js"
import type { add } from "./actions.js"
export function registerConfigHistory(register: typeof add) {
for (const section of CONFIG_SECTIONS) {
const permissionSection = section === "filters" ? "filters" : "network"
register(`config.${section}.revisions.read`, `mm_${section.replaceAll("-", "_")}_revisions_get`, `История конфигурации: ${section}`, permissionSection, `mm:${permissionSection}:read`, "read", "servers", z.object({ serverId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) }).strict(), {
read: async input => {
// Shared REST service returns metadata only, never the stored payload
// (WireGuard/IPsec revisions may contain credentials). Scope evaluation
// happens before this server-specific SQL query.
const revisions = await listRevisions(Number(input.serverId), section)
return { revisions, retentionLimit: CONFIG_REVISION_KEEP, incomplete: revisions.length === CONFIG_REVISION_KEEP }
},
})
}
}
+26
View File
@@ -0,0 +1,26 @@
/** Caller passes the ACTUAL authorized registration descriptors, never caller
* output/state/id or an external list. Lane selection grants no authority:
* existing schema/auth/policy/runtime checks still run in the SDK callback.
*/
interface RegisteredOperation { id: string; toolName: string; effect: string }
const tools: Readonly<Record<string, { id: string; effect: string }>> = Object.freeze({
mm_capabilities_get: { id: "capabilities.read", effect: "read" },
mm_change_get: { id: "changes.read", effect: "read" },
mm_operation_get: { id: "operations.read", effect: "read" },
mm_server_probe_receipt_get: { id: "servers.probes.receipt.read", effect: "read" },
mm_change_cancel: { id: "changes.cancel", effect: "read" },
mm_server_probe_cancel: { id: "servers.probes.cancel", effect: "write" },
})
const protocols = new Set(["initialize", "ping", "tools/list", "notifications/initialized", "notifications/cancelled"])
export function classifyMcpLane(body: unknown, authorizedRegistrations: readonly RegisteredOperation[]): "control" | "normal" {
const item: unknown = Array.isArray(body) && body.length === 1 ? body[0] : body
if (!item || typeof item !== "object" || Array.isArray(item)) return "normal"
const request = item as { method?: unknown; params?: unknown }
if (typeof request.method !== "string") return "normal"
if (protocols.has(request.method)) return "control"
if (request.method !== "tools/call" || !request.params || typeof request.params !== "object") return "normal"
const name = (request.params as { name?: unknown }).name
if (typeof name !== "string" || !Object.hasOwn(tools, name)) return "normal"
const expected = tools[name]
return authorizedRegistrations.some(row => row.toolName === name && row.id === expected.id && row.effect === expected.effect) ? "control" : "normal"
}
@@ -0,0 +1,109 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
import { pool } from "../../db/index.js"
import type { AuthUser } from "../../lib/permissions.js"
import { managedComment } from "../../managed-markers.js"
import { compileManagedFilterRules } from "../../services/filter-rules-write-model.js"
import { authenticateMcpToken, createMcpConnection, setConnectionStatus } from "./store.js"
import { invokeMcpTool } from "./runtime.js"
import { requireRecentPath } from "./path-verification.js"
import { readRouteCommunityContext } from "./routing-community-context.js"
/** Disposable SQL and loopback HTTP only; no CHR or native mutations. */
export async function verifyMcpControlPathContext(actor: AuthUser) {
const sid = 134
let requests = 0, bgpReads = 0, probes = 0, writes = 0, counter = 0, bgpPresent = false, listMember = false
let opaque = "NEVER_CONTROL_PATH_NATIVE", afterTrace: (() => Promise<unknown>) | undefined, afterRead: (() => Promise<unknown>) | undefined
const body = compileManagedFilterRules([{ community: "65000:21", gateway: "192.0.2.1", outIface: "", action: "route", description: "" }])
const collections = (path: string): Record<string, unknown>[] => ({
"/routing/table": ["main", "diag", "policy-only"].map((name, index) => ({ ".id": "*t" + index, name, fib: "true", dynamic: name === "diag" ? "true" : "false" })),
"/ip/vrf": [{ ".id": "*vd", name: "diag", interfaces: listMember ? "WAN-LIST" : "wan", disabled: "false" }, { ".id": "*vm", name: "main", interfaces: "all", disabled: "false" }],
"/interface": ["wan", "hop", "target"].map((name, index) => ({ ".id": "*if" + index, name, disabled: "false", running: "true", "rx-byte": String(++counter), opaque })),
"/ip/address": ["wan", "hop", "target"].map((iface, index) => ({ ".id": "*a" + index, interface: iface, address: "192.0.2." + (index + 1) + "/32", disabled: "false", invalid: "false" })),
"/routing/bgp/connection": [{ ".id": "*c", name: "peer", instance: "default", vrf: "main", "routing-table": "policy-only", afi: "ip", "input.filter": "bgp-in", "output.filter-chain": "", templates: "", disabled: "false" }],
"/routing/bgp/template": [], "/routing/bgp/instance": [{ ".id": "*i", name: "default", vrf: "main", "routing-table": "main", disabled: "false" }],
"/routing/filter/rule": [{ ".id": "*f", chain: "bgp-in", disabled: "false", comment: managedComment("NEVER_CONTROL_PATH_COMMENT"), rule: body }],
"/ip/route": [{ ".id": "*r", "dst-address": "192.0.2.1/32", "routing-table": "main", disabled: "false", comment: managedComment("recursive") }],
} as Record<string, Record<string, unknown>[]>)[path] ?? []
const native = createServer(async (request, response) => {
requests++; response.setHeader("content-type", "application/json")
const path = (request.url ?? "").replace(/^\/rest/, "")
if (afterRead) { const hook = afterRead; afterRead = undefined; await hook() }
if (path.startsWith("/routing/bgp")) { bgpReads++; if (!bgpPresent) { response.statusCode = 404; response.end("{}"); return } }
if (request.method === "GET") { response.end(JSON.stringify(collections(path))); return }
let raw = ""; for await (const part of request) raw += part
const args = raw ? JSON.parse(raw) : {}
if (request.method === "POST" && path.endsWith("/get")) {
const value = collections(path.slice(0, -4)).find(row => row[".id"] === args[".id"])?.[args["value-name"]]
response.end(JSON.stringify(value === undefined ? [] : { ret: value })); return
}
if (request.method === "POST" && ["/tool/ping", "/tool/traceroute"].includes(path)) {
probes++; assert.equal(args.vrf, "diag"); assert.equal(args.interface, "wan"); assert.equal(args["src-address"], "192.0.2.1")
if (path === "/tool/ping") { assert.equal(args.count, "3"); response.end(JSON.stringify([{ sent: "3", received: "3", "packet-loss": "0", "avg-rtt": "1ms" }])); return }
if (afterTrace) { const hook = afterTrace; afterTrace = undefined; await hook() }
response.end(JSON.stringify(["192.0.2.2", "192.0.2.3"].map(address => ({ address, sent: "1", loss: "0" })))); return
}
writes++; response.statusCode = 405; response.end("{}")
})
await new Promise<void>(resolve => native.listen(0, "127.0.0.1", resolve))
let connectionId: string | undefined
try {
assert.equal((await pool.query("SELECT count(*)::int AS count FROM servers WHERE id=$1", [sid])).rows[0].count, 0)
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'control-path-context','127.0.0.1',$2,false,'NEVER_CONTROL_PATH_LOGIN')", [sid, (native.address() as { port: number }).port])
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Control path purpose", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [sid], operationIds: ["routing.path.verify"], allowGlobal: false }, limits: { callsPerMinute: 120 } }), actor)
connectionId = access.connection.id
const principal = await authenticateMcpToken(access.token)
const profile = routeAiProfileSchema.parse({ name: "diagnostic is not Community forwarding", serverId: sid, family: "ipv4", community: "65000:21", table: "policy-only", probeVrf: "diag", bgpContext: { connectionId: "*c", instance: "default", inputChain: "bgp-in", vrf: "main" }, candidates: [{ id: "a", jumpHostId: sid, exitNodeId: sid, wanInterface: "wan", downstreamInterface: "hop", gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { sourceAddress: "192.0.2.1", targetInterface: "target", targetAddress: "192.0.2.3", expectedHops: [{ serverId: sid, interface: "hop", address: "192.0.2.2" }, { serverId: sid, interface: "target", address: "192.0.2.3" }] } }] })
const check = () => invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "a", proofPurpose: "control-path" }) as Promise<{ id: string; status: string; reason?: string; proofPurpose: string; proofVersion: number; proofScope: string; returnPathVerified: boolean }>
const passed = await check()
assert.equal(passed.status, "passed"); assert.equal(passed.proofPurpose, "control-path"); assert.equal(passed.proofVersion, 2)
assert.equal(passed.proofScope, "observed-control-probes-only"); assert.equal(passed.returnPathVerified, false); assert.equal(bgpReads, 0); assert.equal(probes, 2)
const beforeAdopt = requests
await assert.rejects(requireRecentPath(profile, "a", connectionId), (error: { code?: string }) => error.code === "MEASUREMENT_UNKNOWN")
assert.equal(requests, beforeAdopt, "a control receipt is rejected before any native Community acquisition")
const saved = (await pool.query("SELECT evidence FROM mcp_path_checks WHERE id=$1", [passed.id])).rows[0].evidence
await pool.query("UPDATE mcp_path_checks SET evidence=evidence-'proofPurpose'-'proofVersion' WHERE id=$1", [passed.id])
await assert.rejects(requireRecentPath(profile, "a", connectionId), (error: { code?: string }) => error.code === "MEASUREMENT_UNKNOWN")
assert.equal(requests, beforeAdopt, "legacy evidence without a purpose is not upgraded")
await pool.query("UPDATE mcp_path_checks SET evidence=$2::jsonb WHERE id=$1", [passed.id, JSON.stringify({ ...saved, proofPurpose: "community" })])
bgpPresent = true
await assert.rejects(requireRecentPath(profile, "a", connectionId), (error: { code?: string }) => error.code === "MEASUREMENT_UNKNOWN", "relabeling the purpose cannot cross the HMAC domain")
const privateContext = await readRouteCommunityContext(profile)
assert.equal(privateContext.before.scopedExecutionAvailable, true); assert.equal(privateContext.before.executionAvailable, false)
assert.ok(privateContext.ownedTarget); assert.equal(privateContext.ownedTarget.desiredTargetProof(body), privateContext.before.targetRuleProof)
const desiredBody = compileManagedFilterRules([{ community: "65000:21", gateway: "192.0.2.9", outIface: "", action: "route", description: "" }])
const desiredProof = privateContext.ownedTarget.desiredTargetProof(desiredBody)
privateContext.ownedTarget.rawRow.comment = "mutated exposed clone"
assert.equal(privateContext.ownedTarget.desiredTargetProof(desiredBody), desiredProof, "the desired seal uses an immutable original row")
assert.ok(!JSON.stringify(privateContext.before).includes("NEVER_")); assert.ok(!("ownedTarget" in privateContext.before))
await pool.query("UPDATE mcp_path_checks SET evidence=$2::jsonb WHERE id=$1", [passed.id, JSON.stringify(saved)])
bgpPresent = false
const beforeDefault = probes
const defaultPurpose = await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "a" }) as { status: string; proofPurpose: string }
assert.equal(defaultPurpose.proofPurpose, "community"); assert.equal(defaultPurpose.status, "unknown"); assert.equal(probes, beforeDefault)
listMember = true
assert.equal((await check()).status, "unknown", "an unknown earlier interface list cannot fall through to main/all")
listMember = false
afterTrace = async () => { opaque = "NEVER_CONTROL_PATH_DRIFT" }
assert.equal((await check()).reason, "STATE_CONFLICT")
let finalRequests = 0
afterTrace = async () => { await pool.query("UPDATE servers SET password='NEVER_CONTROL_PATH_ROTATED' WHERE id=$1", [sid]); finalRequests = requests }
assert.equal((await check()).reason, "STATE_CONFLICT"); assert.equal(requests, finalRequests, "source drift is checked before polling a replacement endpoint")
const cancelled = new AbortController(), beforeCancel = requests
afterRead = async () => { cancelled.abort() }
await assert.rejects(invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId: "a", proofPurpose: "control-path" }, cancelled.signal))
assert.equal(requests, beforeCancel + 1)
afterRead = () => setConnectionStatus(connectionId!, "paused", actor)
const beforeRevoke = requests
await assert.rejects(check()); assert.equal(requests, beforeRevoke + 1)
assert.equal(writes, 0)
assert.ok(!JSON.stringify((await pool.query("SELECT result FROM mcp_requests WHERE connection_id=$1", [connectionId])).rows).includes("NEVER_"))
console.log("✓ Control path context: BGP-absent bounded probes, independent VRF/IP/interface/hops, explicit purpose and HMAC isolation from Community and legacy evidence, scoped private target API, late dependency/source/cancel/revocation fences, return-path unknown, private results and zero writes")
} finally {
if (connectionId) await setConnectionStatus(connectionId, "paused", actor)
await pool.query("DELETE FROM servers WHERE id=$1", [sid])
native.closeAllConnections(); await new Promise<void>(resolve => native.close(() => resolve()))
}
}
@@ -0,0 +1,87 @@
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { nativeBgpRows, type NativeBgpRow } from "../../services/bgp-export-model.js"
import { stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js"
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
import { fingerprint, McpError, privateFingerprinter, constantEqual } from "./security.js"
import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-observation-source.js"
import { routeContextClients } from "./routing-community-context-native.js"
import { readMeasurementAddress } from "./routing-measurement-address.js"
import type { RouteContextOptions } from "./routing-community-context.js"
import { controlPathVrfOwner } from "./control-path-model.js"
import { pathProofTag } from "./path-proof-purpose.js"
/** A diagnostic proof has no BGP/community dependency and cannot authorize a
* Community switch. Its endpoint, membership and address evidence is private. */
export async function readControlPathContext(profile: RouteAiProfile, candidateId: string, options: RouteContextOptions = {}) {
try {
const candidate = profile.candidates.find(candidate => candidate.id === candidateId), probe = candidate?.controlProbe
if (!candidate || !probe) throw new McpError("MEASUREMENT_UNKNOWN", "Контрольный probe пути не настроен", 409)
const authorize = async () => {
if (options.signal?.aborted) throw new McpError("READ_CANCELLED", "Контрольный путь отменён", 409)
await options.authorize?.()
}
await authorize()
const ids = [...new Set([profile.serverId, candidate.jumpHostId, candidate.exitNodeId, ...probe.expectedHops.map(hop => hop.serverId)])].sort((a, b) => a - b)
const endpoints = await readRoutingEndpoints(ids)
if (endpoints.length !== ids.length || endpoints.some(endpoint => !endpoint.enabled)) throw new McpError("DEVICE_UNAVAILABLE", "Источник контрольного пути отсутствует или отключён", 409)
const sources = await createRoutingSourceContext(endpoints)
if (options.expectedSources && fingerprint(sources.expected) !== fingerprint(options.expectedSources)) throw new McpError("STATE_CONFLICT", "Источник контрольного пути изменился; новый endpoint не опрашивается", 409)
const sign = await privateFingerprinter("route-control-path-context-v1"), clients = routeContextClients(endpoints, options.signal)
const fence = async () => { await authorize(); await sources.assertCurrent() }
const snapshots: unknown[] = [], cache = new Map<string, NativeBgpRow[]>()
let rowsRead = 0
const get = async (serverId: number, path: string) => {
const key = serverId + path, cached = cache.get(key)
if (cached) return cached
await fence()
const rows = nativeBgpRows(await clients.get(serverId)!.get<unknown>(path))
if (rows.length > 1000 || (rowsRead += rows.length) > 10000) throw new McpError("LIMIT_EXCEEDED", "Каталог контрольного пути превышает лимит", 409)
cache.set(key, rows)
snapshots.push({ serverId, path, rows: rows.map(row => path === "/interface" ? Object.fromEntries(Object.entries(row).filter(([key]) => !/^(?:rx-|tx-|fp-|last-link-|link-downs$|driver-)/.test(key))) : stableBgpPolicyRow(row)) })
return rows
}
const post = async (serverId: number, path: string, body: Record<string, string>) => {
await fence()
const value = await clients.get(serverId)!.post<unknown>(path, body)
snapshots.push({ serverId, path, body, value }); return value
}
const enabled = async (serverId: number, path: string, row: NativeBgpRow) => {
const [effective] = await readOspfEffectiveDisabled<NativeBgpRow & { disabled?: string }>([row], id => post(serverId, path + "/get", { ".id": id, "value-name": "disabled" }))
if (![false, "false", "no"].includes(effective.disabled as string | boolean) || [true, "true", "yes"].includes(effective.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "Зависимость контрольного пути отключена или неизвестна", 409)
return effective
}
const unique = (rows: NativeBgpRow[], name: string) => {
const matching = rows.filter(row => row.name === name)
if (matching.length !== 1) throw new McpError("STATE_UNAVAILABLE", "Имя зависимости контрольного пути неоднозначно", 409)
return matching[0]
}
const sourceId = profile.serverId, table = unique(await get(sourceId, "/routing/table"), profile.probeVrf)
if (![true, "true", "yes"].includes(table.fib as string | boolean) || [true, "true", "yes"].includes(table.disabled as string | boolean) || [true, "true", "yes"].includes(table.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB probe VRF контрольного пути не подтверждён", 409)
const vrfs = await get(sourceId, "/ip/vrf"), effectiveVrfs: NativeBgpRow[] = []
await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf))
for (const row of vrfs) effectiveVrfs.push([true, "true", "yes"].includes(row.disabled as string | boolean) ? row : await enabled(sourceId, "/ip/vrf", row))
const interfaces = await get(sourceId, "/interface")
await enabled(sourceId, "/interface", unique(interfaces, candidate.wanInterface))
if (controlPathVrfOwner(effectiveVrfs, new Set(interfaces.map(row => String(row.name))), candidate.wanInterface) !== profile.probeVrf) throw new McpError("STATE_CONFLICT", "WAN интерфейс не принадлежит probe VRF контрольного пути", 409)
const address = async (serverId: number, iface: string, selected?: string) => {
await enabled(serverId, "/interface", unique(await get(serverId, "/interface"), iface))
const path = profile.family === "ipv4" ? "/ip/address" : "/ipv6/address"
const result = await readMeasurementAddress(await get(serverId, path), { dstInterface: iface, dstAddress: selected, family: profile.family }, id => post(serverId, path + "/get", { ".id": id, "value-name": "disabled" }))
if (result.linkLocal) throw new McpError("STATE_UNAVAILABLE", "Link-local контрольный путь требует отдельного scoped probe", 409)
return result.address
}
const destination = await address(candidate.exitNodeId, probe.targetInterface, probe.targetAddress), expected: string[] = []
for (const hop of probe.expectedHops) expected.push(await address(hop.serverId, hop.interface, hop.address))
const source = await address(sourceId, candidate.wanInterface, probe.sourceAddress)
await fence()
const dependencyProof = sign("dependencies:" + fingerprint({ profile, candidateId, sources: sources.expected, snapshots }))
const before = { contextVerified: true, proofPurpose: "control-path" as const, probeVrf: profile.probeVrf, family: profile.family, dependencyProof, targetRuleProof: null, sourceProofs: sources.expected }
return { before, clients, fence, probeEndpoints: new Map([[candidateId, { destination, expected, source }]]),
signEvidence: (value: unknown) => sign(pathProofTag("control-path") + fingerprint({ dependencyProof, value })),
verifyEvidence: (value: unknown, proof: unknown) => typeof proof === "string" && constantEqual(sign(pathProofTag("control-path") + fingerprint({ dependencyProof, value })), proof),
}
} catch (error) {
if (error instanceof McpError) throw error
throw new McpError("STATE_UNAVAILABLE", "Не подтверждены ограниченные зависимости контрольного пути", 409)
}
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { controlPathVrfOwner } from "./control-path-model.js"
import { isCommunityPathEvidence, pathProofTag, PATH_PROOF_VERSION } from "./path-proof-purpose.js"
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
test("control-path and Community proofs require explicit distinct purpose and version", () => {
assert.notEqual(pathProofTag("control-path"), pathProofTag("community"))
assert.equal(isCommunityPathEvidence({ proofPurpose: "community", proofVersion: PATH_PROOF_VERSION }), true)
for (const value of [{}, { proofPurpose: "control-path", proofVersion: PATH_PROOF_VERSION }, { proofPurpose: "community" }, { proofPurpose: "community", proofVersion: 1 }]) assert.equal(isCommunityPathEvidence(value), false)
})
test("control-path membership respects row order and refuses unknown earlier interface lists", () => {
const interfaces = new Set(["wan", "other"]), main = { name: "main", interfaces: "all", disabled: "false" }, diag = { name: "diag", interfaces: "wan", disabled: "false" }
assert.equal(controlPathVrfOwner([diag, main], interfaces, "wan"), "diag")
assert.equal(controlPathVrfOwner([main, diag], interfaces, "wan"), "main")
assert.equal(controlPathVrfOwner([{ ...diag, disabled: "true" }, main], interfaces, "wan"), "main")
for (const row of [{ ...diag, interfaces: "WAN-LIST" }, { ...diag, disabled: undefined }, { ...diag, invalid: "true" }]) assert.throws(() => controlPathVrfOwner([row, main], interfaces, "wan"))
assert.throws(() => controlPathVrfOwner([main], interfaces, "missing"))
})
test("path admission restricts each hop to its candidate devices before context acquisition", () => {
const candidate = { id: "a", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { targetInterface: "target", expectedHops: [{ serverId: 1, interface: "wan" }, { serverId: 2, interface: "hop" }, { serverId: 3, interface: "target" }] } }
const profile = { name: "scoped hops", serverId: 1, community: "65000:21", family: "ipv4", candidates: [candidate, { ...candidate, id: "b", jumpHostId: 4, exitNodeId: 5, controlProbe: undefined }] }
assert.equal(routeAiProfileSchema.safeParse(profile).success, true)
for (const serverId of [4, 99]) {
const invalid = { ...profile, candidates: [{ ...candidate, controlProbe: { ...candidate.controlProbe, expectedHops: [{ serverId, interface: "foreign" }] } }, profile.candidates[1]] }
assert.equal(routeAiProfileSchema.safeParse(invalid).success, false, "a hop outside the selected candidate cannot extend the credential scope, including another candidate's device")
}
})
@@ -0,0 +1,19 @@
import { canonicalVrfMembers } from "../../services/vrf-members.js"
import { McpError } from "./security.js"
/** Native VRF row order is significant. Unknown selectors cannot be skipped in
* favor of a later main/all row; disabled rows are explicitly excluded. */
export function controlPathVrfOwner(vrfs: readonly Record<string, unknown>[], interfaceNames: ReadonlySet<string>, iface: string) {
if (!interfaceNames.has(iface)) throw new McpError("STATE_UNAVAILABLE", "Интерфейс контрольного пути отсутствует", 409)
for (const row of vrfs) {
if ([true, "true", "yes"].includes(row.disabled as string | boolean)) continue
if (![false, "false", "no"].includes(row.disabled as string | boolean) || [true, "true", "yes"].includes(row.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "Состояние VRF контрольного пути не подтверждено", 409)
const members = canonicalVrfMembers(row.interfaces).split(",")
if (members.some(member => !["all", "none"].includes(member) && !interfaceNames.has(member))) throw new McpError("STATE_UNAVAILABLE", "VRF interface-list membership контрольного пути не подтверждено", 409)
if (members.includes("all") || members.includes(iface)) {
if (typeof row.name !== "string" || !row.name) throw new McpError("STATE_UNAVAILABLE", "Имя VRF контрольного пути не подтверждено", 409)
return row.name
}
}
return null
}
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { coverageSchema, collectApplicationRoutes, routeKey } from "./coverage.js"
import { listActions } from "./actions.js"
const coverage = coverageSchema.parse(JSON.parse(await readFile(new URL("../../../mcp-coverage.json", import.meta.url), "utf8")))
const actual = await collectApplicationRoutes()
assert.equal(new Set(coverage.routes.map(routeKey)).size, coverage.routes.length, "Duplicate coverage route")
assert.deepEqual(coverage.routes.map(routeKey).sort(), actual.map(routeKey).sort(), "REST routes changed: update mcp-coverage.json with a typed tool, human-only reason, or explicit pending adapter")
const implemented = new Set(listActions().map(action => action.operation.id))
for (const route of coverage.routes) {
assert.equal(route.status === "mediated", route.operations.length > 0, routeKey(route))
for (const operation of route.operations) assert.ok(implemented.has(operation), `Unknown tool mapping: ${operation} for ${routeKey(route)}`)
if (route.status === "excluded") assert.match(route.path, /^(?:\/api\/(?:mcp(?:\/|$)|auth\/config$)|\/mcp$|\/oauth\/|\/\.well-known\/|\/_internal\/mcp\/|\/health$)/, "Business endpoint cannot be silently excluded")
}
console.log("MCP REST coverage:", JSON.stringify({ tools: listActions().length, routes: actual.length, mediated: coverage.routes.filter(route => route.status === "mediated").length, excluded: coverage.routes.filter(route => route.status === "excluded").length, pending: coverage.routes.filter(route => route.status === "pending").length }))
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod"
import { buildApp } from "../../index.js"
export const coverageEntrySchema = z.object({ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), path: z.string().min(1), status: z.enum(["mediated", "excluded", "pending"]), operations: z.array(z.string()).max(100), reason: z.string().min(15) }).strict()
export const coverageSchema = z.object({ version: z.literal(1), routes: z.array(coverageEntrySchema) }).strict()
export type CoverageEntry = z.infer<typeof coverageEntrySchema>
export const routeKey = (route: Pick<CoverageEntry, "method" | "path">) => route.method + " " + route.path
export async function collectApplicationRoutes() {
const routes = new Map<string, Pick<CoverageEntry, "method" | "path">>()
const app = await buildApp({ logger: false, startScheduler: false, onRoute: route => {
for (const method of Array.isArray(route.method) ? route.method : [route.method]) {
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) continue
const item = { method: method as CoverageEntry["method"], path: route.url }
routes.set(routeKey(item), item)
}
} })
try { await app.ready(); return [...routes.values()].sort((a, b) => routeKey(a).localeCompare(routeKey(b))) } finally { await app.close() }
}
@@ -0,0 +1,52 @@
import { z } from "zod"
import { randomUUID } from "node:crypto"
import { pool } from "../../db/index.js"
import { fingerprint, McpError, redact } from "./security.js"
import { effectiveMcpConnection } from "./store.js"
import { registerMcpService } from "./service-registry.js"
import { createDatabaseBackupArtifact, databaseBackupAuthority, databaseBackupAuthoritySchema, readDatabaseBackupSource, verifyDatabaseBackupArtifact } from "./database-backup-artifact.js"
import type { add } from "./actions.js"
export function registerDatabaseBackupActions(register: typeof add) {
const authority = (input: Record<string, unknown>) => databaseBackupAuthoritySchema.parse(input.authority)
const current = async (input: Record<string, unknown>) => ({ proof: (await readDatabaseBackupSource(authority(input))).proof })
const verify = async (input: Record<string, unknown>, result: unknown) => {
const receipt = result as { artifactId?: string; humanDownloadOnly?: boolean; databaseContentReturned?: boolean; bytes?: unknown; sha256?: unknown } | null
return receipt?.humanDownloadOnly === true && receipt.databaseContentReturned === false && !!receipt.artifactId && await verifyDatabaseBackupArtifact(receipt.artifactId, authority(input), String(input.expectedProof), String(input.attemptRef), receipt)
}
registerMcpService("database.backup", { current,
execute: input => createDatabaseBackupArtifact(authority(input), String(input.expectedProof), z.string().uuid().parse(input.attemptRef)),
verify,
reconcile: async (input, authorize) => {
await authorize()
const access = authority(input), attemptRef = z.string().uuid().parse(input.attemptRef)
const rows = (await pool.query(`SELECT s.intended_state,s.result FROM mcp_operation_steps s
JOIN mcp_operations o ON o.id=s.operation_id JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
WHERE o.connection_id=$1 AND p.operation_id='database.backup' AND s.state='verified'
AND s.intended_state->>'kind'='service' AND s.intended_state->>'service'='database.backup'
AND s.intended_state->'input'->>'attemptRef'=$2
AND octet_length(s.intended_state::text)<=32768 AND octet_length(s.result::text)<=4096 LIMIT 2`, [access.principal.connectionId, attemptRef])).rows
await authorize()
// Creating a dump leaves topology metadata unchanged. Only the exact saved
// verified receipt proves export; a missing acknowledgement stays unresolved.
const exact = rows.length === 1 && fingerprint(rows[0].intended_state) === fingerprint(redact({ kind: "service", service: "database.backup", input }))
const desired = exact && await verify(input, rows[0].result).catch(() => false)
await authorize()
return { matchesDesiredState: desired, matchesOriginalState: false }
},
})
register("database.backup", "mm_database_backup_plan", "Создать полную резервную копию PostgreSQL для скачивания владельцем после подтверждения", "settings", "mm:settings:admin", "write", "global", z.object({}).strict(), {
prepare: async (_input, context) => {
const authorize = async () => {
context.signal?.throwIfAborted()
if (fingerprint((await effectiveMcpConnection(context.principal)).policy) !== fingerprint(context.connection.policy)) throw new McpError("RESET_REQUIRED", "Допуск изменился; подготовьте план заново", 409)
}
await authorize()
const access = await databaseBackupAuthority(context.principal, context.connection.owner_id, context.connection.policy), source = await readDatabaseBackupSource(access)
await authorize()
return { target: { scope: "global" }, objects: [{ kind: "database-backup", id: "global" }, ...source.serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId })), ...source.objects], snapshots: [], before: { proof: source.proof }, requiresHuman: true, mutationCost: 1,
steps: [{ kind: "service", service: "database.backup", input: { authority: access, attemptRef: randomUUID(), expectedProof: source.proof, beforeFingerprint: fingerprint({ proof: source.proof }) } }],
warnings: ["Полная база содержит пароли, ключи и закрытые данные. Архив создаётся только после человеческого подтверждения и доступен вошедшему владельцу 15 минут.", "Модель получает только квитанцию; содержимое базы доступно через человеческий web API. Предел архива — 64 МиБ, экспорта — 120 секунд.", "MCP-экспорт поддерживает PostgreSQL URI с одним host и значениями, представимыми в service file. Неподдержанная форма подключения отклоняется без потери параметров.", "Экспорт временно приостанавливает обработчики базы, планировщик и NetFlow. Отзыв допуска или изменение области блокирует экспорт и выдачу. Автоматического повторения нет."] }
},
})
}
@@ -0,0 +1,138 @@
import { createHash, randomUUID, timingSafeEqual } from "node:crypto"
import { z } from "zod"
import type { PoolClient } from "pg"
import { pool } from "../../db/index.js"
import { env } from "../../config.js"
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
import { exportSystemDatabaseBackupWithEvent } from "../../services/system-database-backup.js"
import { assertVerifiedConfigurationAccess, executeGuardedApplicationWrite } from "../../services/config-mutation-policy.js"
import { DatabaseDumpCommandError, MAX_DATABASE_DUMP_BYTES } from "../../services/bounded-database-dump.js"
import { databaseDumpServiceFile } from "../../services/database-dump-connection.js"
import { MAX_LOG_ARTIFACT_TOTAL_CIPHER_BYTES } from "./log-artifacts.js"
import { assertPortalClaimsCurrent } from "./portal-revocation.js"
import { effectiveMcpConnection, transaction, type McpPrincipal } from "./store.js"
import { accessGeneration, decryptBinary, encryptBinary, fingerprint, McpError, privateSecretFingerprint } from "./security.js"
export const MAX_DATABASE_BACKUP_CIPHER_BYTES = Math.ceil((MAX_DATABASE_DUMP_BYTES + 28) / 3) * 4
const digest = z.string().regex(/^[a-f0-9]{64}$/)
export const databaseBackupAuthoritySchema = z.object({
principal: z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.iso.datetime() }).strict(),
ownerId: z.string().min(1).max(200), policyProof: digest, generation: digest, endpointProof: digest,
}).strict()
export type DatabaseBackupAuthority = z.infer<typeof databaseBackupAuthoritySchema>
const scopeSchema = z.object({ version: z.literal(1), kind: z.literal("database-backup"), authority: databaseBackupAuthoritySchema,
attemptRef: z.string().uuid(), sourceProof: digest, bytes: z.number().int().positive().max(MAX_DATABASE_DUMP_BYTES), sha256: digest, encryptedSha256: digest, proof: digest }).strict()
type Header = { id: string; connection_id: string; filename: string; media_type: string; expires_at: Date }
type Meta = Header & { access_scope: z.infer<typeof scopeSchema>; encrypted_bytes: number }
const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex")
const sign = (value: unknown) => privateSecretFingerprint(fingerprint(value), "database-backup-artifact-v1")
const endpoint = () => privateSecretFingerprint(env.DATABASE_URL, "database-backup-endpoint-v1")
const signed = (header: Header, scope: Omit<z.infer<typeof scopeSchema>, "proof">) => ({ id: header.id, connection_id: header.connection_id, filename: header.filename, media_type: header.media_type, expires_at: header.expires_at.toISOString(), scope })
const fail = (message: string, code = "ARTIFACT_INVALID") => new McpError(code, message, 409)
/** Only bounded topology and protection metadata is read here; never pg_dump or archive bytes. */
export async function readDatabaseBackupSource(raw: unknown, client: Pick<PoolClient, "query"> = pool) {
const authority = databaseBackupAuthoritySchema.parse(raw), connection = await effectiveMcpConnection(authority.principal, client)
if (connection.owner_id !== authority.ownerId || !hasPermission(connection.owner_permissions, "mm:settings:admin") || !hasPermission(connection.policy.permissions, "mm:settings:admin") || !connection.policy.scope.allowGlobal || !connection.policy.scope.operationIds.includes("database.backup")) throw new McpError("PERMISSION_DENIED", "Нет допуска к полной базе приложения", 403)
if (fingerprint(connection.policy) !== authority.policyProof || await accessGeneration() !== authority.generation || await endpoint() !== authority.endpointProof) throw fail("Допуск к базе изменился", "RESET_REQUIRED")
// Security epochs rotate for endpoint/credential/enabled changes (including
// A→B→A), while ordinary scheduler telemetry updates keep the proof stable.
const servers = (await client.query("SELECT id,routing_endpoint_epoch AS revision FROM servers ORDER BY id LIMIT 1001")).rows
const protections = (await client.query("SELECT CASE WHEN octet_length(object_ref::text)<=32768 THEN object_ref ELSE NULL END AS object_ref,xmin::text AS revision FROM mcp_protections ORDER BY object_ref::text LIMIT 1001")).rows
if (servers.length > 1000 || servers.some(row => !z.string().uuid().safeParse(row.revision).success) || protections.length > 1000 || protections.some(row => !row.object_ref)) throw fail("Область полной базы превышает предел", "STATE_UNAVAILABLE")
const serverIds = servers.map(row => Number(row.id))
if (serverIds.some(id => !Number.isSafeInteger(id) || id < 1 || !connection.policy.scope.serverIds.includes(id))) throw fail("Полная база содержит сервер вне подключения", "SERVER_DENIED")
if (protections.some(row => row.object_ref.protection === "deny")) throw fail("Полная база содержит запрещённый объект", "PROTECTED_OBJECT")
// Validate representability during planning/current checks after access and
// full-scope protection checks, before approval or an execution intent exists.
// This is pure validation: no service file, PostgreSQL child or public URI.
try { databaseDumpServiceFile(env.DATABASE_URL) }
catch (error) {
if (error instanceof DatabaseDumpCommandError) throw new McpError(error.code, error.message, 409)
throw error
}
return { serverIds, objects: protections.map(row => row.object_ref), proof: await sign({ authority, servers, protections }) }
}
export async function databaseBackupAuthority(principal: McpPrincipal, ownerId: string, policy: unknown) {
return databaseBackupAuthoritySchema.parse({ principal, ownerId, policyProof: fingerprint(policy), generation: await accessGeneration(), endpointProof: await endpoint() })
}
export async function createDatabaseBackupArtifact(authority: DatabaseBackupAuthority, expectedProof: string, attemptRef: string) {
z.string().uuid().parse(attemptRef)
const source = await readDatabaseBackupSource(authority)
const authorize = async () => { await assertVerifiedConfigurationAccess(); if ((await readDatabaseBackupSource(authority)).proof !== expectedProof) throw fail("Область базы изменилась после подтверждения", "STATE_CONFLICT") }
return executeGuardedApplicationWrite({ kind: "database-backup", id: "global" }, source.serverIds, async () => {
let stage = "authorize"
try {
await authorize()
stage = "export"
const result = await exportSystemDatabaseBackupWithEvent({ authorize })
stage = "export-authorize"
await authorize()
if (!result.buffer.subarray(0, 5).equals(Buffer.from("PGDMP"))) throw fail("Экспорт не является архивом PostgreSQL")
stage = "encrypt"
const ciphertext = (await encryptBinary(result.buffer)).toString("base64")
if (Buffer.byteLength(ciphertext) > MAX_DATABASE_BACKUP_CIPHER_BYTES) throw fail("Зашифрованный архив превышает предел", "EXPORT_LIMIT")
const header: Header = { id: randomUUID(), connection_id: authority.principal.connectionId, filename: result.filename, media_type: "application/octet-stream", expires_at: new Date(Date.now() + 900000) }
const access = { version: 1 as const, kind: "database-backup" as const, authority, attemptRef, sourceProof: expectedProof, bytes: result.buffer.length, sha256: hash(result.buffer), encryptedSha256: hash(ciphertext) }
const scope = scopeSchema.parse({ ...access, proof: await sign(signed(header, access)) })
stage = "publish"
await transaction(async client => {
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [header.connection_id])
if ((await readDatabaseBackupSource(authority, client)).proof !== expectedProof) throw fail("Область базы изменилась", "STATE_CONFLICT")
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('mcp-log-artifact-storage',0))")
await client.query("DELETE FROM mcp_artifacts WHERE expires_at<=now()")
const usage = (await client.query("SELECT count(*) FILTER(WHERE connection_id=$1 AND access_scope->>'kind'='database-backup')::text AS files,COALESCE(sum(octet_length(ciphertext)),0)::text AS total_bytes FROM mcp_artifacts", [header.connection_id])).rows[0]
if (BigInt(usage.files) >= 2n || BigInt(usage.total_bytes) + BigInt(Buffer.byteLength(ciphertext)) > BigInt(MAX_LOG_ARTIFACT_TOTAL_CIPHER_BYTES)) throw new McpError("EXPORT_QUOTA_EXCEEDED", "Квота временных архивов исчерпана", 429)
await client.query("INSERT INTO mcp_artifacts(id,connection_id,filename,media_type,ciphertext,access_scope,expires_at) VALUES($1,$2,$3,$4,$5,$6::jsonb,$7)", [header.id, header.connection_id, header.filename, header.media_type, ciphertext, JSON.stringify(scope), header.expires_at])
})
stage = "publication-authorize"
await authorize()
return { artifactId: header.id, downloadPath: `/api/mcp/artifacts/${header.id}`, expiresInSeconds: 900, bytes: access.bytes, sha256: access.sha256, humanDownloadOnly: true, databaseContentReturned: false, configurationApplied: false,
warnings: ["listenerError", "schedulerError", "eventError"].filter(key => !!result[key as keyof typeof result]) }
} catch (error) {
if (process.env.NODE_ENV === "test") {
const code = error instanceof DatabaseDumpCommandError || error instanceof McpError ? error.code : error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^[0-9A-Z]{5}$/.test(error.code) ? error.code : null
console.error("Database backup failure diagnostic:", JSON.stringify({ stage, code, ...(error instanceof DatabaseDumpCommandError ? { diagnosticKinds: error.diagnosticKinds, exitCode: error.exitCode } : {}) }))
}
if (error instanceof DatabaseDumpCommandError) throw new McpError(error.code, error.message, 503)
throw error
}
}, async () => (await readDatabaseBackupSource(authority)).objects)
}
async function loadMeta(id: string): Promise<Meta> {
const row = (await pool.query<Meta>("/* MCP database artifact metadata */ SELECT id,connection_id,filename,media_type,expires_at,CASE WHEN octet_length(access_scope::text)<=32768 THEN access_scope ELSE NULL END AS access_scope,octet_length(ciphertext)::int AS encrypted_bytes FROM mcp_artifacts WHERE id=$1 AND expires_at>now() AND octet_length(filename)<=1024 AND octet_length(media_type)<=200", [id])).rows[0]
if (!row) throw new McpError("NOT_FOUND", "Архив отсутствует или срок истёк", 404)
const scope = scopeSchema.safeParse(row.access_scope)
if (!scope.success || !(row.expires_at instanceof Date) || !Number.isFinite(row.expires_at.getTime()) || !Number.isSafeInteger(row.encrypted_bytes) || row.encrypted_bytes < 1 || row.encrypted_bytes > MAX_DATABASE_BACKUP_CIPHER_BYTES) throw fail("Метаданные архива не подтверждены")
const { proof, ...access } = scope.data, expected = await sign(signed(row, access))
if (!timingSafeEqual(Buffer.from(proof, "hex"), Buffer.from(expected, "hex")) || row.connection_id !== access.authority.principal.connectionId) throw fail("Подпись архива не совпала")
return { ...row, access_scope: scope.data }
}
export async function verifyDatabaseBackupArtifact(id: string, authority: DatabaseBackupAuthority, sourceProof: string, attemptRef: string, receipt?: { bytes?: unknown; sha256?: unknown }) {
const meta = await loadMeta(id), source = await readDatabaseBackupSource(authority)
if (fingerprint(meta.access_scope.authority) !== fingerprint(authority) || meta.access_scope.attemptRef !== attemptRef || meta.access_scope.sourceProof !== sourceProof || source.proof !== sourceProof || (receipt && (receipt.bytes !== meta.access_scope.bytes || receipt.sha256 !== meta.access_scope.sha256))) return false
const payload = (await pool.query<{ ciphertext: string }>("SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND access_scope->>'proof'=$2 AND expires_at>now() AND octet_length(ciphertext)<=$3", [id, meta.access_scope.proof, MAX_DATABASE_BACKUP_CIPHER_BYTES])).rows[0]
return !!payload && hash(payload.ciphertext) === meta.access_scope.encryptedSha256 && (await readDatabaseBackupSource(authority)).proof === sourceProof
}
export async function humanDatabaseBackupMetadata(id: string, actor: AuthUser, checkClaims = assertPortalClaimsCurrent) {
const meta = await loadMeta(id), scope = meta.access_scope
await checkClaims(actor)
if (actor.id !== scope.authority.ownerId || !hasPermission(actor.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Архив доступен только владельцу с правом администрирования", 403)
if ((await readDatabaseBackupSource(scope.authority)).proof !== scope.sourceProof) throw fail("Область архива изменилась", "STATE_CONFLICT")
return scope
}
export async function readHumanDatabaseBackupArtifact(id: string, actor: AuthUser, checkClaims = assertPortalClaimsCurrent, signal?: AbortSignal) {
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(45000)]) : AbortSignal.timeout(45000)
const meta = await loadMeta(id), scope = meta.access_scope
const authorize = async () => { bounded.throwIfAborted(); await humanDatabaseBackupMetadata(id, actor, checkClaims); bounded.throwIfAborted(); if (meta.expires_at.getTime() <= Date.now()) throw fail("Срок архива истёк") }
await authorize()
const payload = (await pool.query<{ ciphertext: string }>("/* MCP database artifact payload */ SELECT ciphertext FROM mcp_artifacts WHERE id=$1 AND access_scope->>'proof'=$2 AND expires_at>now() AND octet_length(ciphertext)<=$3", [id, scope.proof, MAX_DATABASE_BACKUP_CIPHER_BYTES])).rows[0]
await authorize()
if (!payload || hash(payload.ciphertext) !== scope.encryptedSha256) throw fail("Зашифрованный архив изменился")
let content: Buffer
try { content = await decryptBinary(Buffer.from(payload.ciphertext, "base64")) } catch { throw fail("Архив не расшифрован") }
await authorize()
if (content.length !== scope.bytes || hash(content) !== scope.sha256 || !content.subarray(0, 5).equals(Buffer.from("PGDMP"))) throw fail("Размер или SHA-256 архива не совпал")
await authorize()
return { filename: meta.filename, mediaType: meta.media_type, content }
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { executeMcpDatabaseMaintenance } from "./database-maintenance.js"
test("restore invalidates access after durable intent and before target database work", async () => {
const calls: string[] = []
assert.deepEqual(await executeMcpDatabaseMaintenance(async () => { calls.push("restore"); return { restored: true } }, { record: async state => { calls.push(state) }, invalidateAccess: async () => { calls.push("invalidate") }, close: async () => { calls.push("close") } }), { restored: true })
assert.deepEqual(calls, ["intent", "invalidate", "restore", "applied", "close"])
})
test("failed intent or generation invalidation prevents restore", async () => {
for (const stage of ["intent", "invalidate"]) {
let writes = 0
await assert.rejects(executeMcpDatabaseMaintenance(async () => { writes++; return {} }, { record: async state => { if (stage === state) throw new Error(stage) }, invalidateAccess: async () => { if (stage === "invalidate") throw new Error(stage) }, close: async () => {} }), new RegExp(stage))
assert.equal(writes, 0)
}
})
test("post-restore audit errors remain warnings and never trigger command replay", async () => {
let writes = 0
const result = await executeMcpDatabaseMaintenance(async () => { writes++; return { restored: true } }, { record: async state => { if (state === "applied") throw new Error("disk") }, invalidateAccess: async () => {}, close: async () => {} })
assert.equal(writes, 1); assert.equal(result.restored, true); assert.match(result.maintenanceError!, /Не повторяйте/)
})
test("unconfirmed-journal and close failures preserve a failed restore's original error", async () => {
const original = new Error("restore failed")
await assert.rejects(executeMcpDatabaseMaintenance(async () => { throw original }, { record: async state => { if (state === "unconfirmed") throw new Error("journal") }, invalidateAccess: async () => {}, close: async () => { throw new Error("close") } }), error => error === original)
})
@@ -0,0 +1,21 @@
interface MaintenanceDependencies {
record: (state: "intent" | "applied" | "unconfirmed") => Promise<void>
invalidateAccess: () => Promise<void>
close: () => Promise<void>
}
/** A confirmed restore cannot be relabelled failed by a later journal error. */
export async function executeMcpDatabaseMaintenance<T extends object>(work: () => Promise<T>, deps: MaintenanceDependencies): Promise<T & { maintenanceError?: string }> {
let result: T | undefined, failed = false, failure: unknown, warning: string | undefined
try {
await deps.record("intent")
await deps.invalidateAccess()
result = await work()
} catch (error) {
failed = true; failure = error
try { await deps.record("unconfirmed") } catch { /* preserve the primary outcome */ }
}
if (!failed) try { await deps.record("applied") } catch { warning = "База восстановлена, но внешний журнал MCP не подтвердил завершение. Не повторяйте restore." }
try { await deps.close() } catch { warning ??= "Не удалось закрыть внешний журнал восстановления MCP" }
if (failed) throw failure
return { ...result!, ...(warning ? { maintenanceError: warning } : {}) }
}
@@ -0,0 +1,222 @@
import { createHash, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { readFile, realpath, unlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { isDeepStrictEqual } from "node:util"
import { setTimeout as delay } from "node:timers/promises"
import pg from "pg"
import { captureMigrationData, compareMigrationData, type CapturedMigrationData } from "../../db/migration-data-inventory.js"
// Private destructive acceptance helper. No application entry point imports it.
class OwnedRestoreDataError extends Error {}
function refuse(category: string): never { throw new OwnedRestoreDataError(`Owned restore data equivalence refused: ${category}`) }
const identifier = (value: string) => '"' + value.replaceAll('"', '""') + '"'
const literal = (value: string) => "'" + value.replaceAll("'", "''") + "'"
const profileSql = `SELECT pg_catalog.pg_encoding_to_char(d.encoding) AS encoding,
d.datcollate AS collate,d.datctype AS ctype,d.datlocprovider::text AS provider,
pg_catalog.to_jsonb(d)->>'datlocale' AS locale,pg_catalog.to_jsonb(d)->>'daticurules' AS rules,
d.datcollversion AS version,t.spcname AS tablespace,pg_catalog.pg_get_userbyid(d.datdba) AS owner,
d.datacl::text AS acl,pg_catalog.shobj_description(d.oid,'pg_database') AS comment
FROM pg_catalog.pg_database d JOIN pg_catalog.pg_tablespace t ON t.oid=d.dattablespace
WHERE d.datname=pg_catalog.current_database()`
async function snapshot(pool: pg.Pool): Promise<CapturedMigrationData> {
const client = await pool.connect()
try {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
await client.query(`SET LOCAL search_path=pg_catalog; SET LOCAL TimeZone='UTC'; SET LOCAL DateStyle='ISO, YMD';
SET LOCAL standard_conforming_strings=on; SET LOCAL statement_timeout='5s'; SET LOCAL row_security=off;
SET LOCAL extra_float_digits=3; SET LOCAL bytea_output='hex'; SET LOCAL IntervalStyle='postgres';
SET LOCAL client_encoding='UTF8'; SET LOCAL lc_monetary='C'; SET LOCAL lc_numeric='C'`)
return await captureMigrationData(client)
} finally {
try { await client.query("ROLLBACK") } finally { client.release() }
}
}
/** pg_restore receives no URI/password argv; discard every diagnostic byte. */
async function restoreClone(file: string, url: URL, database: string) {
const childEnvironment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^PG/i.test(key)))
Object.assign(childEnvironment, { PGHOST: "127.0.0.1", PGPORT: url.port || "5432", PGUSER: decodeURIComponent(url.username),
PGDATABASE: database, PGPASSWORD: decodeURIComponent(url.password), PGCONNECT_TIMEOUT: "5" })
await new Promise<void>((resolve, reject) => {
const child = spawn("pg_restore", ["--single-transaction", "--exit-on-error", "--no-password", "--dbname", database, file],
{ env: childEnvironment, shell: false, windowsHide: true, stdio: ["ignore", "ignore", "pipe"] })
let failed = false, bytes = 0
const timer = setTimeout(() => { failed = true; child.kill("SIGKILL") }, 120000)
child.stderr.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 1048576) { failed = true; child.kill("SIGKILL") } })
child.on("error", () => { failed = true })
child.on("close", code => { clearTimeout(timer); if (failed || code !== 0) reject(new OwnedRestoreDataError("Owned restore data equivalence refused: archive restore")); else resolve() })
})
}
function difference(expected: CapturedMigrationData, actual: CapturedMigrationData): string {
if (expected.schemaSha256 !== actual.schemaSha256) return "schema"
for (const name of ["tables", "sequences"] as const) {
const left = expected[name], right = actual[name]
for (let index = 0; index < Math.max(left.length, right.length); index++) {
if (!isDeepStrictEqual(left[index], right[index])) {
const relation = left[index] ?? right[index]
// Catalog names only; never interpolate driver messages or row values.
return `${name}: ${JSON.stringify([relation.namespace, relation.name])}`
}
}
}
return "evidence"
}
export interface OwnedRestoreDataComparisonInput {
operationId: string
receipt: Record<string, unknown>
actorId: string
startedAt: number
}
async function prepareExpectation(archive: Buffer) {
// These checks deliberately precede config/security/configured-pool imports.
if (process.env.MMAPP_OWN_TERMINAL_DATA_EQUIVALENCE !== "true" || process.env.MMAPP_OWN_TERMINAL_RESTORE !== "true") refuse("owned opt-in")
let url: URL
try { url = new URL(process.env.DATABASE_URL ?? "") } catch { refuse("owned connection") }
const state = path.resolve(process.env.MCP_STATE_DIR ?? "")
if (!["postgres:", "postgresql:"].includes(url.protocol) || url.hostname !== "127.0.0.1" || decodeURIComponent(url.username) !== "mcp_test" || url.pathname !== "/postgres" ||
!process.env.MMAPP_OWN_SQL_DB || decodeURIComponent(url.pathname.slice(1)) !== process.env.MMAPP_OWN_SQL_DB || url.search || url.hash ||
!process.env.MCP_STATE_DIR || path.basename(state) !== "state" || !/^mm-mcp-test-[A-Za-z0-9-]+$/.test(path.basename(path.dirname(state)))) refuse("owned connection/state")
if (path.resolve(await realpath(state)).toLowerCase() !== state.toLowerCase()) refuse("owned state path")
if (!Buffer.isBuffer(archive) || archive.length < 5 || archive.length > 64 * 1024 * 1024 || archive.subarray(0, 5).toString() !== "PGDMP") refuse("archive")
const { pool: target } = await import("../../db/index.js")
const { env } = await import("../../config.js")
if (env.DATABASE_URL !== url.toString() && env.DATABASE_URL !== process.env.DATABASE_URL) refuse("configured connection")
const { redact } = await import("./security.js")
const identity = (await target.query("SELECT current_database() AS db,session_user AS owner,current_user AS active_owner")).rows[0]
if (identity.db !== process.env.MMAPP_OWN_SQL_DB || identity.owner !== "mcp_test" || identity.active_owner !== "mcp_test") refuse("target identity")
const profile = (await target.query(profileSql)).rows[0]
if (!profile || profile.owner !== "mcp_test" || profile.comment !== null || profile.acl !== null || profile.tablespace !== "pg_default") refuse("source database profile")
const archiveSha256 = createHash("sha256").update(archive).digest("hex")
const suffix = randomUUID().replaceAll("-", ""), database = `mm_restore_data_${suffix}`
const file = path.join(state, `restore-data-${suffix}.dump`)
const cloneUrl = new URL(url); cloneUrl.pathname = "/" + database
const clone = new pg.Pool({ connectionString: cloneUrl.toString(), max: 1, connectionTimeoutMillis: 5000, statement_timeout: 5000 })
let created = false, fileOwned = false, closed = false, comparing = false
let verifiedReceipt: { context: unknown; audit: unknown } | undefined
let beforeConsumers: Readonly<{
equal: true; equalityPhase: "before-consumers"; evidenceOnly: true; ready: false; enrollmentAuthorized: false;
allReplicaQuiescence: false; externalQuiescence: false; archiveSnapshotValidated: true;
captureStartedAt: number; capturedAt: number; tableCount: number; sequenceCount: number; rowCount: number; sha256: string
}> | undefined
const close = async () => {
if (closed) return
closed = true
let cleanupFailed = false
try { await clone.end() } catch { cleanupFailed = true }
if (created) {
try {
if (!/^mm_restore_data_[a-f0-9]{32}$/.test(database) || database !== `mm_restore_data_${suffix}`) refuse("cleanup database identity")
const row = (await target.query("SELECT datname,pg_catalog.pg_get_userbyid(datdba) AS owner FROM pg_catalog.pg_database WHERE datname=$1", [database])).rows[0]
if (!row || row.datname !== database || row.owner !== "mcp_test") refuse("cleanup database owner")
await target.query(`DROP DATABASE ${identifier(database)}`)
} catch { cleanupFailed = true }
}
if (fileOwned) { try { await unlink(file) } catch { cleanupFailed = true } }
if (cleanupFailed) refuse("owned cleanup")
}
try {
const provider = ({ c: "libc", i: "icu", b: "builtin" } as Record<string, string>)[String(profile.provider)]
if (!provider || typeof profile.encoding !== "string" || typeof profile.collate !== "string" || typeof profile.ctype !== "string") refuse("source locale profile")
let locale = ""
if (provider === "icu" || provider === "builtin") {
if (typeof profile.locale !== "string") refuse("source locale")
locale = ` ${provider === "icu" ? "ICU_LOCALE" : "BUILTIN_LOCALE"} ${literal(profile.locale)}`
}
if (profile.rules !== null) { if (provider !== "icu" || typeof profile.rules !== "string") refuse("source locale rules"); locale += ` ICU_RULES ${literal(profile.rules)}` }
if (profile.version !== null) { if (typeof profile.version !== "string") refuse("source collation version"); locale += ` COLLATION_VERSION ${literal(profile.version)}` }
await target.query(`CREATE DATABASE ${identifier(database)} WITH TEMPLATE template0 OWNER mcp_test ENCODING ${literal(profile.encoding)} LC_COLLATE ${literal(profile.collate)} LC_CTYPE ${literal(profile.ctype)} LOCALE_PROVIDER ${provider}${locale}`)
created = true
await writeFile(file, archive, { flag: "wx", mode: 0o600 }); fileOwned = true
await restoreClone(file, url, database)
if (!isDeepStrictEqual((await clone.query(profileSql)).rows[0], profile)) refuse("clone database profile")
const rawArchive = await snapshot(clone)
compareMigrationData(rawArchive, rawArchive)
return {
async compareBeforeConsumers() {
if (closed || comparing || beforeConsumers) refuse("pre-consumer comparison lifecycle")
comparing = true
try {
const captureStartedAt = Date.now()
// This is the complete raw archive expectation. The caller pauses the
// owned restore after migrations and before any consumer restarts.
// No table/column exception or expected runtime append is applied.
const actual = await snapshot(target)
try { compareMigrationData(rawArchive, actual) } catch { refuse(difference(rawArchive, actual)) }
beforeConsumers = Object.freeze({ equal: true as const, equalityPhase: "before-consumers" as const,
evidenceOnly: true as const, ready: false as const, enrollmentAuthorized: false as const,
allReplicaQuiescence: false as const, externalQuiescence: false as const, archiveSnapshotValidated: true as const,
captureStartedAt, capturedAt: Date.now(), tableCount: actual.tables.length, sequenceCount: actual.sequences.length,
rowCount: actual.rowCount, sha256: actual.sha256 })
return beforeConsumers
} catch (error) {
if (error instanceof OwnedRestoreDataError) throw error
refuse("pre-consumer capture/comparison")
} finally { comparing = false }
},
async compareAfterRestore(input: OwnedRestoreDataComparisonInput) {
if (closed || comparing) refuse("comparison lifecycle")
comparing = true
try {
const { operationId, receipt, actorId, startedAt } = input
if (!/^[a-f0-9-]{36}$/i.test(operationId) || !actorId || !Number.isSafeInteger(startedAt) || startedAt > Date.now() ||
receipt.operationId !== operationId || receipt.state !== "applied" || receipt.accessRevoked !== true || receipt.automaticRetry !== false) refuse("expected receipt")
const finishedAt = typeof receipt.finishedAt === "string" ? Date.parse(receipt.finishedAt) : NaN
if (!beforeConsumers || !Number.isFinite(finishedAt) || finishedAt > Date.now() ||
beforeConsumers.captureStartedAt < startedAt || beforeConsumers.capturedAt > finishedAt ||
beforeConsumers.captureStartedAt > beforeConsumers.capturedAt) refuse("pre-consumer evidence window")
if (typeof receipt.archiveRef !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(receipt.archiveRef)) refuse("receipt archive reference")
const vaultFile = path.join(state, "restore-vault", receipt.archiveRef)
const metadata = JSON.parse(await readFile(vaultFile + ".archive.json", "utf8")) as Record<string, unknown>
const used = JSON.parse(await readFile(vaultFile + ".used", "utf8")) as Record<string, unknown>
if (!metadata || metadata.id !== receipt.archiveRef || metadata.sha256 !== archiveSha256 || metadata.sizeBytes !== archive.length ||
metadata.ownerId !== actorId || !used || used.operationId !== operationId) refuse("archive binding")
const expectedPayload = JSON.parse(JSON.stringify(redact(receipt))) as unknown
const context = { operationId, actorId, startedAt, receipt: expectedPayload }
if (verifiedReceipt && !isDeepStrictEqual(verifiedReceipt.context, context)) refuse("comparison context changed")
const deadline = Date.now() + 10000
let audit: Record<string, unknown> | undefined
for (;;) {
const rows = (await target.query(`SELECT id::text AS id,actor_id,connection_id,payload,created_at::text AS created_at,
(created_at >= to_timestamp($2::double precision/1000) AND created_at <= clock_timestamp()) AS in_window
FROM public.mcp_audit WHERE action='database.restore.receipt' AND payload->>'operationId'=$1 LIMIT 2`, [operationId, startedAt])).rows
if (rows.length > 1) refuse("audit multiplicity")
if (rows.length === 1) { audit = rows[0]; break }
if (Date.now() >= deadline) refuse("audit timeout")
await delay(50)
}
if (!audit || audit.actor_id !== actorId || audit.connection_id !== null || audit.in_window !== true || typeof audit.created_at !== "string" ||
typeof audit.id !== "string" || !/^[1-9]\d*$/.test(audit.id) || !isDeepStrictEqual(audit.payload, expectedPayload)) refuse("audit validation")
const auditIdentity = { id: audit.id, createdAt: audit.created_at }
if (verifiedReceipt && !isDeepStrictEqual(verifiedReceipt.audit, auditIdentity)) refuse("audit receipt changed")
verifiedReceipt = { context, audit: auditIdentity }
// Consumers have resumed by this point. Report the frozen full-data
// observation at its actual phase, then independent receipt evidence;
// do not recapture or manufacture a post-consumer expected database.
return Object.freeze({ ...beforeConsumers, dataEqualBeforeConsumers: true as const,
postAuditReceiptExact: true as const, archiveShaBound: true as const, auditRecordsObserved: 1 as const })
} catch (error) {
if (error instanceof OwnedRestoreDataError) throw error
refuse("capture/comparison")
} finally { comparing = false }
},
close,
}
} catch (error) {
try { await close() } catch { /* Preserve the primary sanitized failure. */ }
if (error instanceof OwnedRestoreDataError) throw error
refuse("preparation")
}
}
export async function prepareOwnedRestoreDataExpectation(archive: Buffer) {
try { return await prepareExpectation(archive) }
catch (error) {
if (error instanceof OwnedRestoreDataError) throw error
refuse("preparation")
}
}
@@ -0,0 +1,177 @@
import assert from "node:assert/strict"
import { randomUUID, createHmac } from "node:crypto"
import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import type { PoolClient } from "pg"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { type AuthUser } from "../../lib/permissions.js"
import { accessGeneration, mcpStateDirectory } from "./security.js"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { uploadRestoreArchive, readRestoreReceipt, executeMcpDatabaseRestore } from "./database-restore.js"
import { exportSystemDatabaseBackup, restoreSystemDatabaseBackup } from "../../services/system-database-backup.js"
import { initializeAutomaticSchedulerRoot } from "../../services/scheduler-automatic-root.js"
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
import { stopSchedulerForDatabaseOperation } from "../../services/scheduler.js"
import { stopTrafficFlowListenerForDatabaseOperation } from "../../services/traffic-flow-ingest.js"
import { acceptPortalRevocation, assertPortalClaimsCurrent } from "./portal-revocation.js"
// This destructive test is only invoked by the private temporary-cluster runner.
const url = new URL(process.env.DATABASE_URL ?? "postgresql://invalid")
assert.equal(process.env.MMAPP_OWN_TERMINAL_RESTORE, "true")
assert.equal(url.hostname, "127.0.0.1")
assert.equal(url.pathname.slice(1), process.env.MMAPP_OWN_SQL_DB)
assert.match(mcpStateDirectory(), /mm-mcp-test-/)
// Standalone owned runner has no buildApp bootstrap; initialize before actor/request scopes.
initializeAutomaticSchedulerRoot()
const actor: AuthUser = { id: "restore-owner", email: "[email protected]", name: "Restore acceptance", apps: ["mm"], permissions: ["mm:settings:admin"], expiresAt: Date.now() + 3600000 }
const verifyData = process.env.MMAPP_OWN_TERMINAL_DATA_EQUIVALENCE === "true"
let dataExpectation: Awaited<ReturnType<typeof import("./database-restore-data-equivalence.sql-helper.js")["prepareOwnedRestoreDataExpectation"]>> | undefined
const originalConnect = pool.connect
let migrationCaptures = 0
try {
// Every owned restore profile archives inert domain collectors, not only the
// data-equivalence profile. Otherwise restart can launch unrelated downloads
// or router polling and race the final scheduler drain. Lifecycle SQL remains
// real; production scheduler configuration and drain checks are unchanged.
for (const table of ["traffic_settings", "uptime_settings", "traffic_flow_settings", "servers_api_ping_settings", "certificate_renew_settings", "backup_schedule_settings", "geoip_settings", "internet_path_settings", "router_log_settings"]) await pool.query(`UPDATE ${table} SET enabled=false WHERE id=1`)
await pool.query("UPDATE uptime_settings SET resources_enabled=false,ping_enabled=false,speed_enabled=false WHERE id=1")
await pool.query("DELETE FROM mcp_protections")
await updateMcpSettings({ enabled: true }, actor)
await pool.query("CREATE TABLE mcp_restore_acceptance_marker (value text NOT NULL)")
await pool.query("INSERT INTO mcp_restore_acceptance_marker VALUES('before')")
const dump = await exportSystemDatabaseBackup()
assert.equal(dump.buffer.subarray(0, 5).toString(), "PGDMP")
if (verifyData) dataExpectation = await (await import("./database-restore-data-equivalence.sql-helper.js")).prepareOwnedRestoreDataExpectation(dump.buffer)
const revokedOwner = { id: "restore-revocation-after-backup", issuedAt: Date.now() - 60000 }
const portalSecret = "restore-acceptance-only-portal-secret-32", stamp = String(Math.floor(Date.now() / 1000))
const portalEvent = Buffer.from(JSON.stringify({ eventId: randomUUID(), userId: revokedOwner.id, type: "permissions.changed" }))
await acceptPortalRevocation(portalEvent, stamp, "sha256=" + createHmac("sha256", portalSecret).update(stamp + ".").update(portalEvent).digest("hex"), portalSecret)
const source = await uploadRestoreArchive(dump.buffer, actor)
const encrypted = await readFile(path.join(mcpStateDirectory(), "restore-vault", source.archiveRef + ".archive.bin"))
assert.ok(!encrypted.equals(dump.buffer))
assert.ok(!encrypted.subarray(0, 5).equals(Buffer.from("PGDMP")))
await assert.rejects(uploadRestoreArchive(dump.buffer, { ...actor, permissions: ["mm:mcp:admin"] }))
await assert.rejects(uploadRestoreArchive(Buffer.from("invalid"), actor))
const serverIds = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id))
// Combined owned profiles retain their evidence/disabled synthetic servers.
// The human restore fixture grants this exact catalog instead of inheriting
// the ordinary five-server default; production policy guards stay unchanged.
assert(serverIds.length <= 1000)
const issue = (owner = actor) => createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Database restore acceptance", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:settings:admin"], scope: { allowGlobal: true, serverIds, operationIds: ["database.restore"] }, limits: { serversPerPlan: Math.max(1, serverIds.length) } }), owner)
const issued = await issue(), principal = await authenticateMcpToken(issued.token)
const other = await issue({ ...actor, id: "another-owner" }), otherPrincipal = await authenticateMcpToken(other.token)
await assert.rejects(invokeMcpTool(otherPrincipal, "mm_database_restore_plan", { archiveRef: source.archiveRef, expectedSha256: source.sha256 }))
await assert.rejects(invokeMcpTool(principal, "mm_database_restore_plan", { archiveRef: source.archiveRef, expectedSha256: "0".repeat(64) }))
const prepared = await invokeMcpTool(principal, "mm_database_restore_plan", { archiveRef: source.archiveRef, expectedSha256: source.sha256 }) as { planId: string; state: string }
assert.equal(prepared.state, "awaiting-approval")
await assert.rejects(applyPlan(principal, prepared.planId, "restore-acceptance"))
await approvePlan(prepared.planId, actor, true)
// Legacy direct restore must respect protected objects in every domain.
const protection = randomUUID()
await pool.query("INSERT INTO mcp_protections(id,object_ref,reason,created_by) VALUES($1,$2::jsonb,'fixture',$3)", [protection, JSON.stringify({ kind: "user", id: "protected-user", protection: "deny" }), actor.id])
await assert.rejects(restoreSystemDatabaseBackup(dump.buffer))
await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protection])
await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after'")
const generation = await accessGeneration()
// Emulates the admitted HTTP apply request: runner must wait, never deadlock it.
const restoreStartedAt = Date.now()
if (dataExpectation) {
// Owned-test observation only: capture after migrations release their
// advisory lease, before the production restore resumes consumers.
pool.connect = function (...args: unknown[]) {
if (args.length) return Reflect.apply(originalConnect, pool, args)
return (Reflect.apply(originalConnect, pool, []) as Promise<PoolClient>).then(client => {
let migrationClient = false
return new Proxy(client, {
get(target, property) {
if (property === "query") return (...queryArgs: unknown[]) => {
const sql = queryArgs[0]
if (typeof sql === "string" && sql.includes("CREATE TABLE IF NOT EXISTS schema_migrations")) migrationClient = true
const result = Reflect.apply(target.query, target, queryArgs)
if (migrationClient && sql === "RESET statement_timeout" && queryArgs.length === 1) {
return Promise.resolve(result).then(async value => {
assert.equal(++migrationCaptures, 1)
await dataExpectation!.compareBeforeConsumers()
return value
})
}
return result
}
const value = Reflect.get(target, property, target)
return typeof value === "function" ? value.bind(target) : value
},
})
})
} as typeof pool.connect
}
const operation = await sharedDatabaseGate.request(() => applyPlan(principal, prepared.planId, "restore-acceptance"))
let receipt: Record<string, unknown> = {}
for (let i = 0; i < 600; i++) {
receipt = await readRestoreReceipt(operation.id, actor).catch(() => ({}))
if (["applied", "unknown", "blocked"].includes(String(receipt.state))) break
await delay(100)
}
assert.equal(receipt.state, "applied", JSON.stringify(receipt))
assert.equal(receipt.accessRevoked, true)
pool.connect = originalConnect
if (dataExpectation) {
await stopSchedulerForDatabaseOperation()
await stopTrafficFlowListenerForDatabaseOperation()
await (await import("../../services/database-background-work.js")).databaseBackgroundWork.drain()
const dataInput = { operationId: operation.id, receipt, actorId: actor.id, startedAt: restoreStartedAt }
await assert.rejects(dataExpectation.compareAfterRestore({ ...dataInput, actorId: "foreign-owner" }))
await assert.rejects(dataExpectation.compareAfterRestore({ ...dataInput, operationId: randomUUID() }))
await assert.rejects(dataExpectation.compareAfterRestore({ ...dataInput, receipt: { ...receipt, accessRevoked: false } }))
console.log("Owned terminal restore data equivalence:", JSON.stringify(await dataExpectation.compareAfterRestore(dataInput)))
assert.equal(migrationCaptures, 1)
assert.equal((await dataExpectation.compareAfterRestore(dataInput)).auditRecordsObserved, 1)
}
assert.equal((await pool.query("SELECT value FROM mcp_restore_acceptance_marker")).rows[0].value, "before")
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE id=$1", [operation.id])).rows[0].n, 0)
assert.notEqual(await accessGeneration(), generation)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_owner_revocations WHERE owner_id=$1", [revokedOwner.id])).rows[0].n, 0)
await assert.rejects(assertPortalClaimsCurrent(revokedOwner))
await assert.rejects(authenticateMcpToken(issued.token))
await assert.rejects(readRestoreReceipt(operation.id, { ...actor, permissions: ["mm:mcp:read"] }))
// A stale callback or process replay cannot invoke pg_restore a second time.
await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'")
await executeMcpDatabaseRestore(operation.id, principal)
assert.equal((await pool.query("SELECT value FROM mcp_restore_acceptance_marker")).rows[0].value, "after-receipt")
assert.equal((await readRestoreReceipt(operation.id, actor)).state, "applied")
if (dataExpectation) {
console.log("Owned restore data negative cases: foreign owner, wrong operation and unconfirmed receipt refused; repeat validation writes no audit; full equality observed before consumers resume")
}
console.log("✓ Terminal restore: real pg_dump/pg_restore transaction, encrypted human upload, approval, global protection, request drain, external receipt, revoked tokens, preserved external owner revocation and no replay")
// Archive authentication is checked before a terminal attempt can replace data.
await updateMcpSettings({ enabled: true }, actor)
const next = await issue(), nextPrincipal = await authenticateMcpToken(next.token), tampered = await uploadRestoreArchive(dump.buffer, actor)
const badPlan = await invokeMcpTool(nextPrincipal, "mm_database_restore_plan", { archiveRef: tampered.archiveRef, expectedSha256: tampered.sha256 }) as { planId: string }
await approvePlan(badPlan.planId, actor, true)
await writeFile(path.join(mcpStateDirectory(), "restore-vault", tampered.archiveRef + ".archive.bin"), Buffer.from("corrupted"))
const generationBeforeCorruption = await accessGeneration()
const bad = await applyPlan(nextPrincipal, badPlan.planId, "corrupted-archive")
// Admission, durable receipt writes and authority checks are asynchronous.
// Use the same terminal-state budget as the successful restore above; never
// interpret a still-running receipt as a rejection or dispatch apply again.
for (let i = 0; i < 600; i++) {
receipt = await readRestoreReceipt(bad.id, actor).catch(() => ({}))
if (["blocked", "applied", "unknown"].includes(String(receipt.state))) break
await delay(100)
}
assert.equal(receipt.state, "blocked", JSON.stringify(receipt))
assert.equal(await accessGeneration(), generationBeforeCorruption)
assert.equal((await pool.query("SELECT value FROM mcp_restore_acceptance_marker")).rows[0].value, "after-receipt")
console.log("✓ Tampered archive is blocked before data replacement")
} finally {
pool.connect = originalConnect
await stopSchedulerForDatabaseOperation()
await stopTrafficFlowListenerForDatabaseOperation()
await dataExpectation?.close()
await closePool()
}
+157
View File
@@ -0,0 +1,157 @@
import { createHash, randomUUID } from "node:crypto"
import { mkdir, open, readFile, readdir, unlink } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import { pool } from "../../db/index.js"
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
import { validateSystemDatabaseRestoreArchive } from "../../services/system-database-restore-execution.js"
import { restoreSystemDatabaseBackup, assertSystemDatabaseRestoreTools } from "../../services/system-database-backup.js"
import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js"
import { accessGeneration, decryptBinary, encryptBinary, decryptSecret, McpError, mcpStateDirectory } from "./security.js"
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
import { publishStateFile } from "./state-file-publish.js"
import type { add } from "./actions.js"
import type { ChangeEvidence } from "./action-types.js"
import { withApplicationMutationLock } from "../../services/config-mutation-lock.js"
import {assertOriginalMcpTemporaryGrant,type OriginalServiceGrant} from "./temporary-grant-origin.js"
export const MAX_RESTORE_ARCHIVE_BYTES = 64 * 1024 * 1024
const metadataSchema = z.object({ id: z.string().uuid(), ownerId: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), sizeBytes: z.number().int().positive().max(MAX_RESTORE_ARCHIVE_BYTES), generation: z.string(), expiresAt: z.string().datetime(), createdAt: z.string().datetime() }).strict()
type Archive = z.infer<typeof metadataSchema>
const vault = () => path.join(mcpStateDirectory(), "restore-vault")
const filename = (id: string, suffix: string) => path.join(vault(), z.string().uuid().parse(id) + suffix)
async function durableWrite(target: string, content: Buffer | string, exclusive = false) {
await mkdir(vault(), { recursive: true, mode: 0o700 })
const destination = exclusive ? target : target + "." + randomUUID() + ".tmp"
const handle = await open(destination, "wx", 0o600)
try { await handle.writeFile(content); await handle.sync() } finally { await handle.close() }
if (!exclusive) {
try { await publishStateFile(destination, target) }
catch (error) { await unlink(destination).catch(() => {}); throw error }
}
}
async function archive(id: string, ownerId: string): Promise<Archive> {
let data: Archive
try { data = metadataSchema.parse(JSON.parse(await readFile(filename(id, ".archive.json"), "utf8"))) }
catch { throw new McpError("NOT_FOUND", "Архив восстановления не найден", 404) }
if (data.id !== id || data.ownerId !== ownerId) throw new McpError("NOT_FOUND", "Архив восстановления не найден", 404)
if (Date.parse(data.expiresAt) <= Date.now() || data.generation !== await accessGeneration()) throw new McpError("PLAN_EXPIRED", "Архив или поколение доступа устарели", 409)
try { await readFile(filename(id, ".used")); throw new McpError("STATE_CONFLICT", "Архив уже использован; для нового восстановления загрузите его заново", 409) }
catch (error) { if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error }
return data
}
export async function uploadRestoreArchive(buffer: Buffer, actor: AuthUser) {
return withApplicationMutationLock(() => uploadArchive(buffer, actor))
}
async function uploadArchive(buffer: Buffer, actor: AuthUser) {
if (!hasPermission(actor.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Для архива нужны административные права системы")
if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > MAX_RESTORE_ARCHIVE_BYTES) throw new McpError("INVALID_INPUT", "Ожидается архив PostgreSQL размером до 64 МиБ", 400)
validateSystemDatabaseRestoreArchive(buffer)
await mkdir(vault(), { recursive: true, mode: 0o700 })
const files = (await readdir(vault())).filter(file => /^[a-f0-9-]{36}\.archive\.json$/.test(file))
let active = 0
for (const file of files) {
const raw = metadataSchema.safeParse(JSON.parse(await readFile(path.join(vault(), file), "utf8")))
if (!raw.success) continue
if (Date.parse(raw.data.expiresAt) > Date.now()) { active++; continue }
if (file !== raw.data.id + ".archive.json") continue
await unlink(filename(raw.data.id, ".archive.bin")).catch(() => {})
await unlink(filename(raw.data.id, ".archive.json")).catch(() => {})
}
if (active >= 4) throw new McpError("BUDGET_EXCEEDED", "Уже загружены четыре действующих архива; дождитесь их истечения", 429)
const data: Archive = { id: randomUUID(), ownerId: actor.id, sha256: createHash("sha256").update(buffer).digest("hex"), sizeBytes: buffer.length, generation: await accessGeneration(), createdAt: new Date().toISOString(), expiresAt: new Date(Math.min(Date.now() + 900000, actor.expiresAt ?? Date.now() + 900000)).toISOString() }
try {
await durableWrite(filename(data.id, ".archive.bin"), await encryptBinary(buffer), true)
await durableWrite(filename(data.id, ".archive.json"), JSON.stringify(data), true)
await audit(pool, actor.id, "database.archive.upload", null, { archiveRef: data.id, sha256: data.sha256, sizeBytes: data.sizeBytes, expiresAt: data.expiresAt })
} catch (error) {
await unlink(filename(data.id, ".archive.bin")).catch(() => {})
await unlink(filename(data.id, ".archive.json")).catch(() => {})
throw error
}
return { archiveRef: data.id, sha256: data.sha256, sizeBytes: data.sizeBytes, expiresAt: data.expiresAt }
}
export async function readRestoreReceipt(id: string, actor: AuthUser) {
if (!hasPermission(actor.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Нужны административные права системы")
try { return JSON.parse(await readFile(filename(id, ".receipt.json"), "utf8")) as Record<string, unknown> }
catch { throw new McpError("NOT_FOUND", "Внешняя квитанция восстановления не найдена", 404) }
}
export function registerDatabaseRestore(register: typeof add) {
register("database.restore", "mm_database_restore_plan", "Восстановить БД из загруженного человеком архива", "settings", "mm:settings:admin", "write", "global", z.object({ archiveRef: z.string().uuid(), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), {
prepare: async (input, context) => {
const source = await archive(String(input.archiveRef), context.connection.owner_id)
if (source.sha256 !== input.expectedSha256) throw new McpError("STATE_CONFLICT", "Хеш архива не совпадает", 409)
try { await assertSystemDatabaseRestoreTools() } catch { throw new McpError("UNSUPPORTED_CAPABILITY", "На backend недоступны pg_restore/psql; установите PostgreSQL client tools", 503) }
const ids = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id))
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Полное восстановление затрагивает серверы вне подключения")
return { target: { scope: "global" }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], steps: [{ kind: "service", service: "database.restore", input: { archiveRef: source.id, sha256: source.sha256 } }], before: { archiveRef: source.id, sha256: source.sha256, sizeBytes: source.sizeBytes }, requiresHuman: true,
warnings: ["Данные приложения будут заменены. Старые MCP-токены, grants и планы будут аннулированы до записи.", "После restore результат читается человеком из внешней квитанции: журнал исходной операции может исчезнуть вместе со старой БД.", "Неопределённый restore не повторяется. Для нового восстановления нужна новая загрузка архива."] }
},
})
}
/** This terminal operation must never hold a database-request admission while asking for an exclusive restore. */
export async function executeMcpDatabaseRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
const lease = await pool.connect()
let locked = false, unsafe = false
try {
locked = (await lease.query<{ locked: boolean }>("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + operationId])).rows[0].locked
if (locked) await executeRestore(operationId, principal,originalGrant)
} finally {
if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true }
lease.release(unsafe)
}
}
async function executeRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) {
const { loadPlan } = await import("./runtime.js")
const { rows: [operation] } = await pool.query("SELECT * FROM mcp_operations WHERE id=$1 AND connection_id=$2", [operationId, principal.connectionId])
if (!operation || operation.state !== "pending") return
const plan = await loadPlan(operation.plan_id), evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence
const payload = JSON.parse(await decryptSecret(plan.payload.sealed)) as { archiveRef: string; expectedSha256: string }
let receipt: Record<string, unknown> = { operationId, archiveRef: payload.archiveRef, state: "running", startedAt: new Date().toISOString(), automaticRetry: false }
try { await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt), true) }
catch (error) { if (error instanceof Error && "code" in error && error.code === "EEXIST") return; throw error }
let attempted = false, stage = "authority"
const assertAuthority = async () => {
await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant)
const connection = await validatePrincipal(principal)
if (!plan.approved_by || plan.state !== "executing" || plan.operation_id !== "database.restore" || plan.expires_at.getTime() <= Date.now() || plan.policy_version !== connection.policy.version || plan.generation !== await accessGeneration() || !connection.policy.scope.allowGlobal || !connection.policy.scope.operationIds.includes("database.restore") || !hasPermission(connection.owner_permissions, "mm:settings:admin") || !hasPermission(connection.policy.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Допуск или подтверждение восстановления устарели")
const ids = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id))
if (ids.some(id => !connection.policy.scope.serverIds.includes(id) || !evidence.objects.some(object => object.kind === "server" && object.serverId === id))) throw new McpError("SERVER_DENIED", "Область восстановления изменилась")
const { rows: grants } = await pool.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [connection.id, principal.credentialId])
if (grants.some(grant => !grant.payload.scope?.allowGlobal || !grant.payload.scope?.operationIds?.includes("database.restore") || ids.some(id => !grant.payload.scope?.serverIds?.includes(id)))) throw new McpError("PERMISSION_DENIED", "Временный допуск не разрешает восстановление")
if ((await pool.query("SELECT 1 FROM mcp_protections WHERE object_ref->>'protection'='deny' LIMIT 1")).rows.length) throw new McpError("PROTECTED_OBJECT", "Восстановление заменяет запрещённый защищённый объект")
return { connection, ids }
}
try {
const { connection, ids } = await assertAuthority(), source = await archive(payload.archiveRef, connection.owner_id)
stage = "archive-read"
if (source.sha256 !== payload.expectedSha256) throw new McpError("STATE_CONFLICT", "Архив подменён", 409)
const buffer = await decryptBinary(await readFile(filename(source.id, ".archive.bin")))
if (createHash("sha256").update(buffer).digest("hex") !== source.sha256) throw new McpError("STATE_CONFLICT", "Содержимое архива изменилось", 409)
if (!(await pool.query("UPDATE mcp_operations SET state='running' WHERE id=$1 AND state='pending' RETURNING id", [operationId])).rowCount) throw new McpError("STATE_CONFLICT", "Операция уже остановлена", 409)
stage = "restore"
const outcome = await withVerifiedConfigurationChange({ actorId: connection.owner_id, connectionId: connection.id, serverIds: ids, objects: evidence.objects, approved: true, serverWide: true }, () => restoreSystemDatabaseBackup(buffer, { beforeRestore: async () => {
await assertAuthority(); await archive(payload.archiveRef, connection.owner_id)
await durableWrite(filename(source.id, ".used"), JSON.stringify({ operationId, at: new Date().toISOString() }), true)
await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant)
attempted = true
} }))
receipt = { ...receipt, state: "applied", finishedAt: new Date().toISOString(), accessRevoked: true, warnings: Object.keys(outcome).filter(key => key.endsWith("Error") && outcome[key as keyof typeof outcome]) }
stage = "receipt-publish"
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
await audit(pool, connection.owner_id, "database.restore.receipt", null, receipt).catch(() => {})
} catch (error) {
// The isolated destructive acceptance runner can print bounded diagnostics
// without archive SQL, server credentials, or arbitrary error text.
if (!(error instanceof McpError) && process.env.MMAPP_OWN_TERMINAL_RESTORE === "true" && process.env.NODE_ENV === "test") {
const message = error instanceof Error ? error.message : ""
const kinds = ["pg_restore", "psql", "already exists", "does not exist", "cannot drop", "constraint", "permission denied", "deadlock", "lock timeout", "out of shared memory", "connection", "syntax error", "duplicate key"].filter(value => message.toLowerCase().includes(value))
const identifiers = [...message.matchAll(/\b(?:table|relation|constraint|index|column) "([a-z_][a-z0-9_]{0,100})"/g)].map(match => match[1]).slice(0, 10)
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^(?:E[A-Z]{1,20}|[0-9A-Z]{5})$/.test(error.code) ? error.code : null
console.error("Owned restore failure diagnostic:", JSON.stringify({ stage, code, kinds, identifiers }))
}
receipt = { ...receipt, state: attempted ? "unknown" : "blocked", errorCode: error instanceof McpError ? error.code : "RESTORE_UNCONFIRMED", finishedAt: new Date().toISOString() }
await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt))
if (!attempted) await pool.query("UPDATE mcp_operations SET state='rejected',error_code=$2,finished_at=now() WHERE id=$1", [operationId, receipt.errorCode]).catch(() => {})
}
}
@@ -0,0 +1,53 @@
import { listContainersState } from "../../services/containers-live.js"
import { listCertificatesState } from "../../services/certificates-service.js"
import { catalogDevices, catalogPaging } from "./catalog-scope.js"
import { listFirewallAll } from "../../services/firewall-live.js"
import type { add } from "./actions.js"
export function registerDeviceCatalogActions(register: typeof add) {
register("firewall.state.read", "mm_firewall_state_get", "Firewall IPv4/IPv6: правила, списки адресов, счётчики и неизвестные состояния", "network", "mm:network:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context), result = await listFirewallAll(selected, true)
const offset = Number(input.offset), limit = Number(input.limit), observed = result.observed!
const collections = { rules: observed.rules, addressLists: observed.addressLists }
return { ...page, serverIds: selected.map(server => server.id), failures: result.failures,
collections: Object.fromEntries(Object.entries(collections).map(([key, rows]) => [key, { items: rows.slice(offset, offset + limit), total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null }])),
incomplete: page.incomplete || result.failures.length > 0 || Object.values(collections).some(rows => rows.slice(offset, offset + limit).length < rows.length),
unknownRuleStates: observed.rules.filter(row => row.enabled === null || row.dynamic === null || row.log === null).length,
unknownRuleCounters: observed.rules.filter(row => row.hits === null).length,
unknownAddressStates: observed.addressLists.filter(row => row.disabled === null || row.dynamic === null).length,
observedAt: new Date().toISOString(), timestampKind: "backend-receipt", packetMatchingVerified: false }
},
})
register("containers.state.read", "mm_containers_state_get", "Наблюдаемое состояние контейнеров без значений env и команд", "network", "mm:network:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context)
const result = await listContainersState(selected), offset = Number(input.offset), limit = Number(input.limit)
let incomplete = page.incomplete
const servers = result.servers.map(server => {
const rows = [...server.containers].sort((a, b) => a.rosId.localeCompare(b.rosId))
incomplete ||= server.availability !== "available" || server.failures.length > 0 || rows.slice(offset, offset + limit).length < rows.length || rows.slice(offset, offset + limit).some(row => row.envs.length > 100)
return { ...server, containers: rows.slice(offset, offset + limit).map(container => ({ ...container,
// Env is a key/value array: generic object-key masking cannot detect
// credentials inside its value. Never pass any value or free-form cmd.
envs: container.envs.slice(0, 100).map(env => ({ key: env.key, value: "[redacted]" })), cmd: undefined,
envsTruncated: container.envs.length > 100,
})), total: server.containers.length, nextOffset: offset + limit < server.containers.length ? offset + limit : null }
})
return { ...result, ...page, servers, incomplete, source: "shared REST container collector", environmentValuesIncluded: false, commandsIncluded: false, forwardingVerified: false }
},
})
register("certificates.state.read", "mm_certificates_state_get", "Наблюдаемые сертификаты: сроки, статус и ошибки устройств", "certificates", "mm:certificates:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context)
const result = await listCertificatesState(selected), offset = Number(input.offset), limit = Number(input.limit)
let incomplete = page.incomplete
const servers = result.servers.map(server => {
const rows = [...server.certificates].sort((a,b) => a.id.localeCompare(b.id))
incomplete ||= server.error !== null || rows.slice(offset, offset + limit).length < rows.length
return { ...server, certificates: rows.slice(offset, offset + limit), total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null }
})
return { ...result, ...page, servers, incomplete }
},
})
}
@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { basename, dirname, resolve } from "node:path"
/** Read/lock acceptance of existing source + current grant components only.
* Does not import, execute or simulate the pending production host patch. */
export async function acceptDiagnosticCancellationSourceSql(serverId: number) {
assert.equal(process.env.MMAPP_OWN_PROBE_STORAGE_JOURNAL, "true")
const url = new URL(process.env.DATABASE_URL ?? "")
assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres")
assert(process.env.MCP_STATE_DIR && basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
const { pool } = await import("../../db/index.js")
const security = await import("./security.js")
const { effectiveMcpConnection } = await import("./store.js")
const { loadDiagnosticCancellationSource, readServerProbeExecutionSource } = await import("./diagnostic-cancellation-source.js")
const operationId = randomUUID(), connectionId = randomUUID(), originalCredentialId = randomUUID(), credentialId = randomUUID(), receiptId = randomUUID(), planId = randomUUID()
const ownerId = "owned-source-" + randomUUID(), at = Date.now(), oldExpiry = new Date(at - 3600000).toISOString(), until = new Date(at + 3600000).toISOString()
const principal = { connectionId, credentialId, credentialExpiresAt: until }
const policy = { id: connectionId, version: 1, status: "active", mode: "approval", permissions: ["mm:servers:write"], expiresAt: until, scope: { serverIds: [serverId], operationIds: ["servers.probes.run"], allowGlobal: false } }
const binding = { version: 1, operation: "servers.probes.run", principal: { connectionId, credentialId: originalCredentialId, credentialExpiresAt: oldExpiry }, ownerId, issuedAt: at - 7200000, authorityExpiresAt: Date.parse(oldExpiry), prepared: { receiptId, serverId, tool: "ping", expiresAt: oldExpiry, bindingCiphertext: await security.encryptSecret("owned synthetic provider request") }, bindingProof: "a".repeat(64), stageBudget: 2 }
const proof = (value: unknown) => security.privateSecretFingerprint(security.fingerprint(value), "server-probe-mcp-binding-v1")
const input = { receiptId, serverId, inputDigest: await proof(binding), bindingCiphertext: await security.encryptSecret(JSON.stringify(binding)) }
const step = { kind: "service", service: "servers.probes.run", input }
const evidence = { steps: [step], target: { scope: "servers", serverIds: [serverId] }, requiresHuman: true, verificationScope: "acknowledgement", mutationCost: 2, validUntil: oldExpiry }
const sealedEvidence = { sealed: await security.encryptSecret(JSON.stringify(evidence)) }
const generation = await security.accessGeneration()
const previousEnabled = (await pool.query("SELECT enabled FROM mcp_settings WHERE id=1")).rows[0].enabled
const cases: string[] = []
await pool.query("UPDATE mcp_settings SET enabled=true WHERE id=1")
try {
await pool.query("INSERT INTO mcp_connections(id,name,owner_id,owner_permissions,policy,generation,owner_valid_until,owner_claims_issued_at) VALUES($1,'owned source acceptance',$2,$3::jsonb,$4::jsonb,$5,$6,clock_timestamp())", [connectionId, ownerId, JSON.stringify(policy.permissions), JSON.stringify(policy), generation, until])
for (const [id, expiry] of [[originalCredentialId, oldExpiry], [credentialId, until]]) await pool.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at) VALUES($1,$2,$3,'bearer',$4)", [id, connectionId, randomUUID(), expiry])
await pool.query("INSERT INTO mcp_plans(id,connection_id,operation_id,operation_version,policy_version,generation,payload,evidence,state,expires_at,approved_by) VALUES($1,$2,'servers.probes.run',1,1,$3,'{}',$4::jsonb,'executing',$5,$6)", [planId, connectionId, generation, JSON.stringify(sealedEvidence), oldExpiry, ownerId])
await pool.query("INSERT INTO mcp_operations(id,plan_id,connection_id,idempotency_key,state) VALUES($1,$2,$3,$4,'pending')", [operationId, planId, connectionId, randomUUID()])
const read = async () => {
const client = await pool.connect()
try {
await client.query("BEGIN"); await client.query("SET LOCAL statement_timeout='2000ms'")
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0))")
return await loadDiagnosticCancellationSource(client, operationId, { connectionId, ownerId })
} finally { await client.query("ROLLBACK"); client.release(true) }
}
const snapshotRead = async () => {
const client = await pool.connect()
try {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
await client.query("SET LOCAL statement_timeout='2000ms'")
const result = await readServerProbeExecutionSource(client, operationId, { connectionId, ownerId })
const settings = (await client.query("SELECT current_setting('transaction_read_only') AS read_only, current_setting('transaction_isolation') AS isolation")).rows[0]
assert.equal(settings.read_only, "on"); assert.equal(settings.isolation, "repeatable read")
assert(Object.isFrozen(result) && Object.isFrozen(result.originalContext) && Object.isFrozen(result.originalContext.identity))
return result
} finally { await client.query("ROLLBACK"); client.release(true) }
}
const source = await read()
const snapshotSource = await snapshotRead()
const { originalContext, ...historicalSource } = snapshotSource
assert.deepEqual(historicalSource, source)
assert.equal(originalContext.identity.executionId, operationId)
assert.equal(originalContext.identity.expiresAt, Date.parse(oldExpiry))
assert.equal(originalContext.identity.kind, "mcp")
cases.push("actual REPEATABLE READ READ ONLY snapshot authenticates original expired context without row locks; locked source digest parity")
assert.equal(source.scope.receiptId, receiptId); assert.equal(source.scope.authorityExpiresAt, oldExpiry)
assert.equal(source.originalCredentialId, originalCredentialId); assert.equal(source.sourceState, "pending"); assert.equal(source.terminal, false)
assert.equal((await effectiveMcpConnection(principal)).owner_id, ownerId)
await assert.rejects(effectiveMcpConnection({ ...principal, credentialId: originalCredentialId, credentialExpiresAt: oldExpiry }))
cases.push("actual encrypted pending source with no step preserves expired original credential while independent current credential validates")
await pool.query("UPDATE mcp_operations SET state='running' WHERE id=$1", [operationId])
await pool.query("INSERT INTO mcp_operation_steps(operation_id,step_index,state,intended_state) VALUES($1,0,'intent',$2::jsonb)", [operationId, JSON.stringify(security.redact(step))])
assert.equal((await read()).sourceState, "running")
cases.push("actual running operation requires exact intent step and source is loaded under owned SQL row locks")
await pool.query("UPDATE mcp_operation_steps SET intended_state='{}'::jsonb WHERE operation_id=$1", [operationId])
await assert.rejects(read()); await assert.rejects(snapshotRead())
await pool.query("UPDATE mcp_operation_steps SET intended_state=$2::jsonb WHERE operation_id=$1", [operationId, JSON.stringify(security.redact(step))])
cases.push("actual intended step drift refuses authenticated source")
await pool.query("UPDATE mcp_plans SET operation_version=2 WHERE id=$1", [planId]); await assert.rejects(read())
await pool.query("UPDATE mcp_plans SET operation_version=1 WHERE id=$1", [planId])
cases.push("actual operation version drift refuses source")
const forged = { ...evidence, steps: [{ ...step, input: { ...input, inputDigest: "b".repeat(64) } }] }
await pool.query("UPDATE mcp_plans SET evidence=$2::jsonb WHERE id=$1", [planId, JSON.stringify({ sealed: await security.encryptSecret(JSON.stringify(forged)) })]); await assert.rejects(read())
await pool.query("UPDATE mcp_plans SET evidence=$2::jsonb WHERE id=$1", [planId, JSON.stringify(sealedEvidence)])
cases.push("actual encrypted evidence with forged keyed input digest refuses source")
for (const state of ["rejected", "partial", "applied", "unknown", "failed", "cancelled"]) {
await pool.query("UPDATE mcp_operations SET state=$2 WHERE id=$1", [operationId, state])
await pool.query("UPDATE mcp_plans SET state='completed' WHERE id=$1", [planId])
const terminal = await read(); assert.equal(terminal.terminal, true); assert.equal(terminal.sourceState, state)
}
cases.push("actual runner terminal states including rejected and partial are recognized as non-executable terminal sources")
const grantId = randomUUID()
await pool.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [grantId, connectionId, JSON.stringify({ id: grantId, credentialId, startsAt: new Date(at - 1000).toISOString(), expiresAt: until, scope: { serverIds: [], operationIds: ["servers.probes.run"], allowGlobal: false } })])
assert.deepEqual((await effectiveMcpConnection(principal)).policy.scope.serverIds, [])
assert.deepEqual((await read()).serverIds, [serverId])
cases.push("actual temporary grant narrows current authority independently of unchanged original source scope")
await pool.query("UPDATE mcp_credentials SET revoked_at=clock_timestamp() WHERE id=$1", [credentialId])
await assert.rejects(effectiveMcpConnection(principal))
cases.push("actual current credential revocation refuses grant validation without rewriting source")
return { cases, actualSql: true, productionCrypto: true, syntheticOriginalExecution: true, productionHostExecuted: false, cancellationsWritten: 0, nativeCalls: 0 }
} finally { await pool.query("UPDATE mcp_settings SET enabled=$1 WHERE id=1", [previousEnabled]) }
}
@@ -0,0 +1,103 @@
import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests } from "./diagnostic-cancellation-source.js"
import { fingerprint, redact } from "./security.js"
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
function fixture() {
const operationId = randomUUID(), connectionId = randomUUID(), credentialId = randomUUID(), receiptId = randomUUID()
const owner = { connectionId, ownerId: "owner" }, sealed = new Map<string,string>()
const binding = { version: 1, operation: "servers.probes.run", principal: { connectionId, credentialId, credentialExpiresAt: "2020-01-01T00:00:00.000Z" }, ownerId: owner.ownerId, issuedAt: 1, authorityExpiresAt: 1577836800000, prepared: { receiptId, serverId: 9, tool: "ping", expiresAt: "2020-01-01T00:00:00.000Z", bindingCiphertext: "original-inner" }, bindingProof: "a".repeat(64), stageBudget: 2 }
const input = { receiptId, serverId: 9, inputDigest: "", bindingCiphertext: "original-binding" }
const step = { kind: "service", service: "servers.probes.run", input }
const evidence = { steps: [step], target: { scope: "servers", serverIds: [9] }, requiresHuman: true, verificationScope: "acknowledgement", validUntil: binding.prepared.expiresAt, mutationCost: 2 }
const row: Record<string,unknown> = { operation_id: operationId, connection_id: connectionId, owner_id: owner.ownerId, operation_state: "running", operation_name: "servers.probes.run", operation_version: 1, plan_state: "executing", approved_by: "human", evidence: { sealed: "original-evidence" }, step_index: 0, step_state: "intent", intended_state: null }
let rows = [row], calls = 0
const refresh = async () => { input.inputDigest = await proof(binding); sealed.set(input.bindingCiphertext, JSON.stringify(binding)); sealed.set("original-evidence", JSON.stringify(evidence)); row.intended_state = redact(step) }
const crypto = { proof, open: async (value: string) => { const raw = sealed.get(value); if (!raw) throw new Error("PRIVATE_SECRET"); return raw } }
const load = createDiagnosticCancellationSourceForTests(crypto), read = createReadServerProbeExecutionSourceForTests(crypto)
const readSql: string[] = []
const client = { query: async (sql: string, values?: unknown[]) => { calls++; assert.ok(sql.includes("LEFT JOIN mcp_operation_steps")); assert.deepEqual(values, [operationId, connectionId, owner.ownerId]); assert.match(sql,/FOR UPDATE OF o,p,c/); assert.ok(!/INSERT|UPDATE|DELETE/.test(sql.replace("FOR UPDATE OF o,p,c",""))); return { rows } } }
const snapshot = { query: async (sql: string, values?: unknown[]) => {
readSql.push(sql); assert.deepEqual(values, [operationId, connectionId, owner.ownerId])
assert.ok(sql.includes("LEFT JOIN mcp_operation_steps")); assert.match(sql, /LIMIT 2$/)
assert.ok(!/FOR UPDATE|FOR SHARE|INSERT|UPDATE|DELETE|BEGIN|COMMIT/.test(sql))
return { rows }
} }
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
refresh, binding, input, evidence, row, sealed, setRows: (value: typeof rows) => { rows=value }, calls: () => calls, operationId, credentialId }
}
{
const f=fixture(); await f.refresh(); const source=await f.load()
assert.equal(source.scope.executionId,f.operationId); assert.equal(source.scope.bindingProof,f.binding.bindingProof); assert.equal(source.originalCredentialId,f.credentialId)
assert.equal(source.scope.authorityExpiresAt,"2020-01-01T00:00:00.000Z"); assert.equal(source.terminal,false); assert.equal(f.calls(),1)
assert.ok(Object.isFrozen(source) && Object.isFrozen(source.scope) && Object.isFrozen(source.serverIds))
assert.equal("originalContext" in source, false)
const original=source.sourceDigest
f.row.operation_state="pending"; f.row.step_index=null; f.row.step_state=null; f.row.intended_state=null
assert.equal((await f.load()).sourceState,"pending")
f.row.step_index=0; f.row.step_state="pending"; f.row.intended_state=redact(f.evidence.steps[0]); assert.equal((await f.load()).terminal,false)
f.row.operation_state="applied"; f.row.plan_state="completed"; f.row.step_state="acknowledged"
const terminal=await f.load(); assert.equal(terminal.terminal,true); assert.equal(terminal.sourceDigest,original)
f.row.approved_by="different-human"; assert.notEqual((await f.load()).sourceDigest,original)
}
const mutations: Array<(f:ReturnType<typeof fixture>)=>void|Promise<void>> = [
f=>{f.setRows([])}, f=>{f.setRows([f.row,f.row])},
f=>{f.row.operation_id=randomUUID()}, f=>{f.row.connection_id=randomUUID()}, f=>{f.row.owner_id="foreign"},
f=>{f.row.operation_name="servers.poll"}, f=>{f.row.operation_version=2}, f=>{f.row.approved_by=null},
f=>{f.row.step_index=1}, f=>{f.row.step_state="applied"}, f=>{f.row.plan_state="approved"}, f=>{f.row.intended_state={ mismatch:true }},
f=>{f.row.evidence={sealed:"missing"}}, f=>{f.sealed.set("original-evidence","x".repeat(2097153))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,steps:[...f.evidence.steps,...f.evidence.steps]}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,requiresHuman:false}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,verificationScope:"configuration"}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,validUntil:"2030-01-01T00:00:00.000Z"}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,target:{scope:"servers",serverIds:[8]}}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,mutationCost:1}))},
async f=>{f.binding.ownerId="foreign";await f.refresh()},
async f=>{f.binding.principal.connectionId=randomUUID();await f.refresh()},
async f=>{f.binding.stageBudget=1;f.evidence.mutationCost=1;await f.refresh()},
f=>{f.sealed.set("original-binding",JSON.stringify({...f.binding,bindingProof:"b".repeat(64)}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,steps:[{...f.evidence.steps[0],input:{...f.input,receiptId:randomUUID()}}]}))},
f=>{f.sealed.set("original-evidence",JSON.stringify({...f.evidence,steps:[{...f.evidence.steps[0],input:{...f.input,serverId:8}}]}))},
async f=>{Object.assign(f.binding,{unexpected:true});await f.refresh()},
]
for (const mutate of mutations) {
const f=fixture();await f.refresh();await mutate(f)
await assert.rejects(f.load(),error=>error instanceof Error && !error.message.includes("PRIVATE_SECRET"))
await assert.rejects(f.read(),error=>error instanceof Error && !error.message.includes("PRIVATE_SECRET"))
}
{
const f = fixture(); await f.refresh()
const locked = await f.load(), snapshot = await f.read()
assert.equal(snapshot.sourceDigest, locked.sourceDigest)
assert.deepEqual(Object.keys(snapshot).filter(key => key !== "originalContext"), Object.keys(locked))
assert.equal(f.readSql.length, 1)
const context = snapshot.originalContext
assert.deepEqual(context, { identity: { kind: "mcp", ownerId: f.binding.ownerId, executionId: f.operationId,
connectionId: f.binding.principal.connectionId, credentialId: f.credentialId, issuedAt: 1,
expiresAt: 1577836800000, operationName: "servers.probes.run" }, sourceServerId: 9, tool: "ping" })
assert.equal("destinationServerId" in context, false)
assert.ok(Object.isFrozen(snapshot) && Object.isFrozen(context) && Object.isFrozen(context.identity))
assert.throws(() => { Object.assign(context.identity, { expiresAt: Date.now() + 300000 }) }, TypeError)
assert.throws(() => { Object.assign(context, { sourceServerId: 8 }) }, TypeError)
assert.equal(context.identity.expiresAt, f.binding.authorityExpiresAt)
assert(context.identity.expiresAt < Date.now())
assert.equal(JSON.stringify(snapshot).includes("original-binding"), false)
assert.equal(JSON.stringify(snapshot).includes("original-inner"), false)
// The historical loader needs neither today's credential nor live authority.
f.row.operation_state = "unknown"; f.row.plan_state = "expired"
const terminal = await f.read(); assert.equal(terminal.terminal, true)
assert.equal(terminal.originalContext.identity.expiresAt, context.identity.expiresAt)
assert.equal(terminal.sourceDigest, snapshot.sourceDigest)
}
{
const f = fixture()
Object.assign(f.binding.prepared, { tool: "bandwidth", destinationServerId: 12 })
f.binding.stageBudget = 1; f.evidence.mutationCost = 1; f.evidence.target.serverIds = [9, 12]
await f.refresh()
const source = await f.read()
assert.deepEqual(source.serverIds, [9, 12]); assert.equal(source.originalContext.destinationServerId, 12)
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
}
console.log(`diagnostic-cancellation-source: locked/read-only source, expired original contexts and ${mutations.length} shared tamper checks passed`)
@@ -0,0 +1,117 @@
import { z } from "zod"
import type { ProbeStorageScope, ProbeStorageSql } from "../../services/server-probe-journal-maintenance.js"
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
const MAX_BYTES = 2097152
const uuid = z.string().uuid()
const deny = (): never => { throw new McpError("STATE_CONFLICT", "Исходная привязка отмены диагностики не подтверждена", 409) }
const sealedSchema = z.object({ sealed: z.string().min(1).max(MAX_BYTES) }).strict()
interface SourceCrypto { open(value: string): Promise<string>; proof(value: unknown): Promise<string> }
export interface DiagnosticCancellationOwner { connectionId: string; ownerId: string }
export interface DiagnosticCancellationSource {
readonly scope: Readonly<ProbeStorageScope>
readonly sourceDigest: string
readonly sourceState: string
readonly terminal: boolean
readonly serverIds: readonly number[]
readonly stageBudget: number
readonly originalCredentialId: string
}
export interface ServerProbeExecutionSource extends DiagnosticCancellationSource {
readonly originalContext: Readonly<ServerProbeJournalContext> & { readonly identity: Readonly<Extract<ServerProbeJournalContext["identity"], { kind: "mcp" }>> }
}
const productionCrypto: SourceCrypto = {
open: decryptSecret,
proof: value => privateSecretFingerprint(fingerprint(value), "server-probe-mcp-binding-v1"),
}
const sourceSelect = `/* diagnostic-cancellation:source */
SELECT o.id AS operation_id,o.connection_id,c.owner_id,o.state AS operation_state,
p.operation_id AS operation_name,p.operation_version,p.state AS plan_state,p.approved_by,
CASE WHEN octet_length(p.evidence::text)<=2097152 THEN p.evidence ELSE NULL END AS evidence,
s.step_index,s.state AS step_state,s.intended_state
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
JOIN mcp_connections c ON c.id=o.connection_id
LEFT JOIN mcp_operation_steps s ON s.operation_id=o.id
WHERE o.id=$1 AND o.connection_id=$2 AND c.owner_id=$3 LIMIT 2`
/** SQL-free authenticated historical decoding; deliberately no current-access
* check, clock or native/source lookup. Expired original contexts stay expired. */
async function validateSourceRow(row: Record<string, unknown>, operationId: string, owner: Readonly<DiagnosticCancellationOwner>, crypto: SourceCrypto): Promise<Readonly<ServerProbeExecutionSource>> {
if (row.operation_id !== operationId || row.connection_id !== owner.connectionId || row.owner_id !== owner.ownerId || row.operation_name !== SERVER_PROBE_OPERATION || row.operation_version !== 1 || typeof row.approved_by !== "string" || !row.approved_by.trim()) return deny()
const wrapper = sealedSchema.safeParse(row.evidence)
if (!wrapper.success || Buffer.byteLength(JSON.stringify(row.evidence), "utf8") > MAX_BYTES) return deny()
let evidence: Record<string, unknown>
try {
const plaintext = await crypto.open(wrapper.data.sealed)
if (Buffer.byteLength(plaintext, "utf8") > MAX_BYTES) return deny()
const parsed: unknown = JSON.parse(plaintext)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return deny()
evidence = parsed as Record<string, unknown>
} catch { return deny() }
const steps = z.array(z.object({ kind: z.literal("service"), service: z.literal(SERVER_PROBE_OPERATION), input: z.record(z.string(), z.unknown()) }).strict()).length(1).safeParse(evidence.steps)
if (!steps.success) return deny()
const step = steps.data[0]
let opened: Awaited<ReturnType<typeof openServerProbeMcpBinding>>
try { opened = await openServerProbeMcpBinding(step.input, crypto) } catch { return deny() }
const { binding, input } = opened
const serverIds = [...new Set([binding.prepared.serverId, ...(binding.prepared.destinationServerId !== undefined ? [binding.prepared.destinationServerId] : [])])].sort((a,b) => a-b)
const expectedBudget = binding.prepared.tool === "mtu" ? 11 : ["ping", "traceroute"].includes(binding.prepared.tool) ? 2 : 1
if (binding.ownerId !== owner.ownerId || binding.principal.connectionId !== owner.connectionId || binding.stageBudget !== expectedBudget || evidence.mutationCost !== binding.stageBudget || evidence.requiresHuman !== true || evidence.verificationScope !== "acknowledgement" || evidence.validUntil !== binding.prepared.expiresAt || fingerprint(evidence.target) !== fingerprint({ scope: "servers", serverIds })) return deny()
const state = z.enum(["pending", "running", "applied", "unknown", "failed", "cancelled", "rejected", "partial"]).safeParse(row.operation_state)
if (!state.success) return deny()
const terminal = state.data !== "pending" && state.data !== "running"
// Queued operations have no step until dispatch in the current runner.
const absentStep = row.step_index === null && row.step_state === null && row.intended_state === null
if (state.data === "pending" ? row.plan_state !== "executing" || (!absentStep && row.step_state !== "pending") : state.data === "running" ? row.plan_state !== "executing" || row.step_state !== "intent" : !["executing", "completed", "cancelled", "expired", "invalidated"].includes(String(row.plan_state))) return deny()
if (!absentStep && (row.step_index !== 0 || fingerprint(row.intended_state) !== fingerprint(redact({ kind: "service", service: SERVER_PROBE_OPERATION, input })))) return deny()
if (state.data === "running" && absentStep) return deny()
const scope: Readonly<ProbeStorageScope> = Object.freeze({ ownerId: binding.ownerId, receiptId: input.receiptId, identityKind: "mcp", executionId: operationId, bindingProof: binding.bindingProof, authorityExpiresAt: new Date(binding.authorityExpiresAt).toISOString() })
const sourceDigest = await crypto.proof({ operationId, connectionId: owner.connectionId, ownerId: owner.ownerId, evidence: wrapper.data, intendedState: row.intended_state, approvedBy: row.approved_by })
const identity = Object.freeze({ kind: "mcp" as const, ownerId: binding.ownerId, executionId: operationId,
connectionId: binding.principal.connectionId, credentialId: binding.principal.credentialId,
issuedAt: binding.issuedAt, expiresAt: binding.authorityExpiresAt, operationName: SERVER_PROBE_OPERATION })
const originalContext = Object.freeze({ identity, sourceServerId: binding.prepared.serverId,
...(binding.prepared.destinationServerId !== undefined ? { destinationServerId: binding.prepared.destinationServerId } : {}), tool: binding.prepared.tool })
return Object.freeze({ scope, sourceDigest, sourceState: state.data, terminal, serverIds: Object.freeze(serverIds), stageBudget: binding.stageBudget, originalCredentialId: binding.principal.credentialId, originalContext })
}
function checkLookup(operationId: string, owner: Readonly<DiagnosticCancellationOwner>) {
uuid.parse(operationId); uuid.parse(owner.connectionId)
if (!owner.ownerId || owner.ownerId.length > 200) return deny()
}
function sourceLoader(crypto: SourceCrypto) {
return async (client: ProbeStorageSql, operationId: string, owner: Readonly<DiagnosticCancellationOwner>): Promise<Readonly<DiagnosticCancellationSource>> => {
checkLookup(operationId, owner)
// Existing cancellation path retains the host-owned transaction and locks.
const { rows } = await client.query(sourceSelect + " FOR UPDATE OF o,p,c", [operationId, owner.connectionId, owner.ownerId])
if (rows.length !== 1) return deny()
const source = await validateSourceRow(rows[0], operationId, owner, crypto)
// Preserve the existing cancellation result; historical context is private
// to the explicit read-only loader, not an expanded cancellation response.
return Object.freeze({ scope: source.scope, sourceDigest: source.sourceDigest, sourceState: source.sourceState,
terminal: source.terminal, serverIds: source.serverIds, stageBudget: source.stageBudget, originalCredentialId: source.originalCredentialId })
}
}
function readSourceLoader(crypto: SourceCrypto) {
return async (client: ProbeStorageSql, operationId: string, owner: Readonly<DiagnosticCancellationOwner>): Promise<Readonly<ServerProbeExecutionSource>> => {
checkLookup(operationId, owner)
// Fixed SELECT without row locks. The trusted caller owns the bounded
// READ ONLY REPEATABLE READ snapshot; no SQL/lock-mode flag is accepted.
const { rows } = await client.query(sourceSelect, [operationId, owner.connectionId, owner.ownerId])
if (rows.length !== 1) return deny()
return validateSourceRow(rows[0], operationId, owner, crypto)
}
}
/** Internal host boundary: authenticate source only; host must check CURRENT
* cancellation authority separately before any durable cancellation write. */
export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
export const createDiagnosticCancellationSourceForTests = sourceLoader
/** Historical authentication only; confers no execution/cancellation grant. */
export const readServerProbeExecutionSource = readSourceLoader(productionCrypto)
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
@@ -0,0 +1,262 @@
import { MIGRATION_MANIFEST } from "../../db/migration-manifest.js"
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { readFile, realpath, mkdir } from "node:fs/promises"
import { basename, dirname, resolve } from "node:path"
import type { PoolClient, QueryConfig } from "pg"
// Destructive acceptance is restricted to the runner's private temporary PG.
// All checks precede importing the configured pool or the approved host.
assert.equal(process.env.MMAPP_OWN_DIAGNOSTIC_CANCEL_HOST, "true")
const url = new URL(process.env.DATABASE_URL ?? "")
assert.equal(url.hostname, "127.0.0.1"); assert.equal(decodeURIComponent(url.username), "mcp_test")
assert.equal(url.pathname, "/postgres"); assert.equal(url.pathname.slice(1), process.env.MMAPP_OWN_SQL_DB)
assert(process.env.MCP_STATE_DIR)
const ownedState = resolve(process.env.MCP_STATE_DIR)
assert.equal(basename(ownedState), "state"); assert.match(basename(dirname(ownedState)), /^mm-mcp-test-[A-Za-z0-9-]+$/)
assert.equal(resolve(await realpath(dirname(ownedState))).toLowerCase(), dirname(ownedState).toLowerCase())
await mkdir(ownedState, { recursive: true, mode: 0o700 })
assert.equal(resolve(await realpath(ownedState)).toLowerCase(), ownedState.toLowerCase())
const { pool, closePool } = await import("../../db/index.js")
const { applySqlMigrations } = await import("../../db/migrate.js")
const security = await import("./security.js")
const { requestStorageDiagnosticCancellation, createStorageDiagnosticCancellationRequest, PROBE_STORAGE_LIMITS } = await import("./diagnostic-cancellation-storage.js")
const { createProbeJournalStorage, ProbeStorageUnknown } = await import("../../services/server-probe-journal-maintenance.js")
const { SERVER_PROBE_OPERATION } = await import("../../services/server-probe-authority.js")
const cases: string[] = []
const pass = (name: string) => { cases.push(name); console.log("✓ Owned cancellation host: " + name) }
let serverId: number
async function fixture(running = false) {
const ownerId = "cancel-host-" + randomUUID(), connectionId = randomUUID(), credentialId = randomUUID(), originalCredentialId = randomUUID()
const operationId = randomUUID(), planId = randomUUID(), receiptId = randomUUID(), at = Date.now()
const until = new Date(at + 3600000).toISOString(), originalUntil = new Date(at + 1800000).toISOString()
const principal = { connectionId, credentialId, credentialExpiresAt: until }
const policy = { id: connectionId, version: 1, status: "active", mode: "approval", permissions: ["mm:servers:write"], expiresAt: until,
scope: { serverIds: [serverId], operationIds: [SERVER_PROBE_OPERATION], allowGlobal: false } }
const binding = { version: 1, operation: SERVER_PROBE_OPERATION, principal: { connectionId, credentialId: originalCredentialId, credentialExpiresAt: originalUntil },
ownerId, issuedAt: at - 1000, authorityExpiresAt: Date.parse(originalUntil), prepared: { receiptId, serverId, tool: "ping", expiresAt: originalUntil,
bindingCiphertext: await security.encryptSecret("owned synthetic request; no provider exists") }, bindingProof: "a".repeat(64), stageBudget: 2 }
const input = { receiptId, serverId, inputDigest: await security.privateSecretFingerprint(security.fingerprint(binding), "server-probe-mcp-binding-v1"),
bindingCiphertext: await security.encryptSecret(JSON.stringify(binding)) }
const step = { kind: "service", service: SERVER_PROBE_OPERATION, input }
const evidence = { steps: [step], target: { scope: "servers", serverIds: [serverId] }, requiresHuman: true, verificationScope: "acknowledgement", mutationCost: 2, validUntil: originalUntil }
const sealed = { sealed: await security.encryptSecret(JSON.stringify(evidence)) }, generation = await security.accessGeneration()
await pool.query("INSERT INTO mcp_connections(id,name,owner_id,owner_permissions,policy,generation,owner_valid_until,owner_claims_issued_at) VALUES($1,'owned cancellation host',$2,$3::jsonb,$4::jsonb,$5,$6,clock_timestamp())", [connectionId, ownerId, JSON.stringify(policy.permissions), JSON.stringify(policy), generation, until])
for (const [id, expiry] of [[credentialId, until], [originalCredentialId, originalUntil]]) await pool.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at) VALUES($1,$2,$3,'bearer',$4)", [id, connectionId, randomUUID(), expiry])
await pool.query("INSERT INTO mcp_plans(id,connection_id,operation_id,operation_version,policy_version,generation,payload,evidence,state,expires_at,approved_by) VALUES($1,$2,$3,1,1,$4,'{}',$5::jsonb,'executing',$6,$7)", [planId, connectionId, SERVER_PROBE_OPERATION, generation, JSON.stringify(sealed), originalUntil, ownerId])
await pool.query("INSERT INTO mcp_operations(id,plan_id,connection_id,idempotency_key,state) VALUES($1,$2,$3,$4,$5)", [operationId, planId, connectionId, randomUUID(), running ? "running" : "pending"])
if (running) await pool.query("INSERT INTO mcp_operation_steps(operation_id,step_index,state,intended_state) VALUES($1,0,'intent',$2::jsonb)", [operationId, JSON.stringify(security.redact(step))])
const scope = { ownerId, receiptId, identityKind: "mcp" as const, executionId: operationId, bindingProof: binding.bindingProof, authorityExpiresAt: originalUntil }
return { ownerId, connectionId, credentialId, originalCredentialId, operationId, planId, receiptId, principal, policy, scope, binding, at }
}
type Fixture = Awaited<ReturnType<typeof fixture>>
const quota = async (ownerId: string) => (await pool.query("SELECT scope,admissions::text,reserved_files::text,reserved_bytes::text FROM server_probe_storage_usage WHERE scope=ANY($1::text[]) ORDER BY scope", [["global", "owner:" + ownerId]])).rows
async function counts(f: Fixture) {
return (await pool.query(`SELECT (SELECT count(*)::int FROM mcp_diagnostic_cancellations WHERE operation_id=$1) AS cancellations,
(SELECT count(*)::int FROM mcp_audit WHERE action='diagnostic.cancel-requested' AND payload->>'operationId'=$1::text) AS audits,
(SELECT count(*)::int FROM server_probe_storage_admissions WHERE receipt_id=$2) AS admissions`, [f.operationId, f.receiptId])).rows[0]
}
async function absent(f: Fixture, before: Awaited<ReturnType<typeof quota>>) {
assert.deepEqual(await counts(f), { cancellations: 0, audits: 0, admissions: 0 }); assert.deepEqual(await quota(f.ownerId), before)
}
type Hook = (text: string, client: PoolClient) => Promise<void>
async function proxyHost<T>(work: () => Promise<T>, hooks: { before?: Hook; after?: Hook } = {}) {
const saved = pool.connect, original = pool.connect.bind(pool), backends = new Set<number>(), texts: string[] = []
let checkouts = 0, nestedCallbacks = 0
// Wrapping checked-out objects never mutates a raw client's query method.
// Pool.query's callback overload passes straight through to the real pool.
pool.connect = ((callback?: unknown) => {
if (typeof callback === "function") { nestedCallbacks++; return (original as (...args: unknown[]) => unknown)(callback) }
return original().then(client => {
checkouts++; backends.add((client as PoolClient & { processID: number }).processID)
const rawQuery = client.query.bind(client)
return new Proxy(client, { get(target, key) {
if (key === "query") return (...args: unknown[]) => {
if (typeof args.at(-1) === "function") return (rawQuery as (...args: unknown[]) => unknown)(...args)
return (async () => {
const text = typeof args[0] === "string" ? args[0] : (args[0] as QueryConfig).text
texts.push(text); await hooks.before?.(text, client)
const result = await (rawQuery as (...args: unknown[]) => Promise<unknown>)(...args)
await hooks.after?.(text, client); return result
})()
}
const value = Reflect.get(target, key, target)
return typeof value === "function" ? value.bind(target) : value
} })
})
}) as typeof pool.connect
try { return { result: await work(), checkouts, backends, texts, nestedCallbacks } }
finally { pool.connect = saved }
}
async function activeClaim(f: Fixture) {
const storage = createProbeJournalStorage({ policy: { ...PROBE_STORAGE_LIMITS, retentionDays: 30 }, safetyWindowMs: 60000,
connect: async () => { const client = await pool.connect(); return { query: (text, values) => client.query(text, values), release: destroy => client.release(destroy) } }, authorize: async () => "c".repeat(64) })
await storage.claim(f.scope, { deadlineAt: Date.now() + 120000 }, async sql => {
await sql.query(`INSERT INTO server_probe_executions(receipt_id,binding_proof,identity_kind,execution_id,actor_id,authentication,mcp_connection_id,mcp_credential_id,authority_issued_at,authority_expires_at,source_server_id,tool)
VALUES($1,$2,'mcp',$3,$4,'mcp',$5,$6,$7,$8,$9,'ping')`, [f.receiptId, f.binding.bindingProof, f.operationId, f.ownerId, f.connectionId, f.originalCredentialId, f.binding.issuedAt, f.scope.authorityExpiresAt, serverId])
})
return storage
}
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM schema_migrations")).rows[0].n, MIGRATION_MANIFEST.length)
await pool.query("UPDATE traffic_settings SET enabled=false; UPDATE uptime_settings SET enabled=false,resources_enabled=false,ping_enabled=false,speed_enabled=false; UPDATE mcp_settings SET enabled=true,requests_retention_days=30")
const installed = (await pool.query("SELECT to_regclass('public.server_probe_storage_admissions') AS name")).rows[0].name
if (!installed) {
const ddl = await pool.connect()
try { await ddl.query(await readFile(new URL("../../db/proposals/server-probe-storage-v2.sql", import.meta.url), "utf8")) } finally { ddl.release(true) }
}
serverId = Number((await pool.query("INSERT INTO servers(name,host,port,enabled) VALUES('owned-cancel-host','127.0.0.1',9,true) RETURNING id")).rows[0].id)
{
const f = await fixture(true); const storage = await activeClaim(f), before = await quota(f.ownerId)
const observed = await proxyHost(() => requestStorageDiagnosticCancellation(f.principal, f.operationId))
assert.deepEqual(observed.result, { operationId: f.operationId, cancelRequested: true, terminal: false, automaticReplay: false })
assert.equal(observed.checkouts, 1); assert.equal(observed.backends.size, 1); assert.equal(observed.nestedCallbacks, 0)
assert(observed.texts.findIndex(text => text.includes("probe-storage:lock")) < observed.texts.findIndex(text => text.includes("diagnostic-cancellation:source")))
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); assert.deepEqual(await quota(f.ownerId), before)
await requestStorageDiagnosticCancellation(f.principal, f.operationId)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); assert.deepEqual(await quota(f.ownerId), before)
const row = (await pool.query("SELECT claim_token,cancelled_at FROM server_probe_storage_admissions WHERE receipt_id=$1", [f.receiptId])).rows[0]
assert.equal(row.claim_token, null); assert(row.cancelled_at)
await assert.rejects(storage.claim(f.scope, { deadlineAt: Date.now() + 120000 }, async () => assert.fail("Cancelled identity cannot execute again")))
assert.deepEqual(await quota(f.ownerId), before)
pass("actual active v2 claim cancels once on one backend after storage lock; duplicate shares quota without debit/refund or replay")
}
{
const f = await fixture(); const before = await quota(f.ownerId)
await requestStorageDiagnosticCancellation(f.principal, f.operationId)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 })
const after = await quota(f.ownerId), owner = after.find(row => row.scope === "owner:" + f.ownerId)
assert(owner); assert.equal(owner.admissions, "1"); assert.equal(owner.reserved_bytes, "294912"); assert.equal(owner.reserved_files, "1")
assert.equal(BigInt(after.find(row => row.scope === "global")!.reserved_bytes) - BigInt(before.find(row => row.scope === "global")!.reserved_bytes), 294912n)
await requestStorageDiagnosticCancellation(f.principal, f.operationId); assert.deepEqual(await quota(f.ownerId), after)
pass("pending source cancellation creates exactly one charged admission and one audit; duplicate changes nothing")
}
{
const f = await fixture(); await pool.query("UPDATE mcp_operations SET state='applied' WHERE id=$1;", [f.operationId])
await pool.query("UPDATE mcp_plans SET state='completed' WHERE id=$1", [f.planId]); const before = await quota(f.ownerId)
assert.deepEqual(await requestStorageDiagnosticCancellation(f.principal, f.operationId), { operationId: f.operationId, cancelRequested: false, terminal: true, automaticReplay: false })
await absent(f, before); pass("terminal source returns a no-op without cancellation, audit or quota mutation")
}
for (const reason of ["wrong-owner", "scope", "credential", "source", "settings"] as const) {
const f = await fixture(), before = await quota(f.ownerId)
if (reason === "wrong-owner") await pool.query("UPDATE mcp_connections SET owner_id='different-owned-user' WHERE id=$1", [f.connectionId])
if (reason === "scope") await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{scope,serverIds}','[]'::jsonb) WHERE id=$1", [f.connectionId])
if (reason === "credential") await pool.query("UPDATE mcp_credentials SET revoked_at=clock_timestamp() WHERE id=$1", [f.credentialId])
if (reason === "source") await pool.query("UPDATE mcp_plans SET operation_version=2 WHERE id=$1", [f.planId])
if (reason === "settings") await pool.query("UPDATE mcp_settings SET enabled=false WHERE id=1")
try { await assert.rejects(requestStorageDiagnosticCancellation(f.principal, f.operationId)); await absent(f, before) }
finally { if (reason === "settings") await pool.query("UPDATE mcp_settings SET enabled=true WHERE id=1") }
pass(reason + " refusal writes no cancellation/audit/admission and changes no quota")
}
for (const reason of ["retention", "credential", "source"] as const) {
const f = await fixture(), before = await quota(f.ownerId), mutator = await pool.connect()
let changed = false
try {
await assert.rejects(proxyHost(() => requestStorageDiagnosticCancellation(f.principal, f.operationId), { before: async text => {
if (changed || !text.includes("probe-storage:lock")) return
changed = true
if (reason === "retention") await mutator.query("UPDATE mcp_settings SET requests_retention_days=31 WHERE id=1")
if (reason === "credential") await mutator.query("UPDATE mcp_credentials SET revoked_at=clock_timestamp() WHERE id=$1", [f.credentialId])
if (reason === "source") await mutator.query("UPDATE mcp_plans SET operation_version=2 WHERE id=$1", [f.planId])
} }))
assert(changed); await absent(f, before)
} finally { mutator.release(true); if (reason === "retention") await pool.query("UPDATE mcp_settings SET requests_retention_days=30 WHERE id=1") }
pass(reason + " drift committed on another backend before global lock is reauthorized and leaves atomic zero writes")
}
{
const f = await fixture(), before = await quota(f.ownerId)
let failed = false
await assert.rejects(proxyHost(() => requestStorageDiagnosticCancellation(f.principal, f.operationId), { after: async text => {
if (!failed && text.startsWith("INSERT INTO mcp_audit")) { failed = true; throw Error("Owned audit acknowledgement failure before COMMIT") }
} }))
assert(failed); await absent(f, before)
pass("failure after actual cancellation/audit INSERT before COMMIT rolls back cancellation, audit, admission and all quota")
}
{
const f = await fixture(); let lost = false
await assert.rejects(proxyHost(() => createStorageDiagnosticCancellationRequest()(f.principal, f.operationId), { after: async text => {
if (!lost && text === "COMMIT") { lost = true; throw Error("Owned lost actual COMMIT acknowledgement") }
} }), ProbeStorageUnknown)
assert(lost); assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); const after = await quota(f.ownerId)
await requestStorageDiagnosticCancellation(f.principal, f.operationId)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); assert.deepEqual(await quota(f.ownerId), after)
pass("lost actual cancellation COMMIT ACK reports unknown; independent repeat observes one spent admission and no second audit/debit")
}
{
const f = await fixture(true); await activeClaim(f); const before = await quota(f.ownerId)
const observed = await proxyHost(() => Promise.all([
requestStorageDiagnosticCancellation(f.principal, f.operationId),
requestStorageDiagnosticCancellation(f.principal, f.operationId),
]))
assert(observed.result.every(result => result.cancelRequested === true))
assert.equal(observed.checkouts, 2); assert.equal(observed.nestedCallbacks, 0)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); assert.deepEqual(await quota(f.ownerId), before)
pass("two concurrent actual host requests serialize without deadlock and commit one cancellation/audit against one shared admission")
}
{
const f = await fixture(), observer = await pool.connect()
let reached!: () => void, release!: () => void
let actualCommitObserved = false
const committed = new Promise<void>(resolve => { reached = resolve })
const resume = new Promise<void>(resolve => { release = resolve })
const pending = proxyHost(() => requestStorageDiagnosticCancellation(f.principal, f.operationId), { after: async text => {
if (text === "COMMIT") { actualCommitObserved = true; reached(); await resume }
} })
const timer = setTimeout(() => { reached(); release() }, 30000)
try {
await committed
assert(actualCommitObserved, "Host must reach the actual COMMIT barrier before timeout")
const durable = (await observer.query("SELECT count(*)::int AS n FROM mcp_diagnostic_cancellations WHERE operation_id=$1", [f.operationId])).rows[0].n
assert.equal(durable, 1, "Independent backend sees actual COMMIT before the host receives its acknowledgement")
await observer.query("UPDATE mcp_operations SET state='applied' WHERE id=$1", [f.operationId])
await observer.query("UPDATE mcp_plans SET state='completed' WHERE id=$1", [f.planId])
release()
const observed = await pending
assert.equal(observed.result.cancelRequested, true); assert.equal(observed.checkouts, 1); assert.equal(observed.nestedCallbacks, 0)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 })
pass("terminal transition after actual cancellation COMMIT but before host acknowledgement preserves the committed stop record")
} finally { clearTimeout(timer); release(); await pending.catch(() => {}); observer.release(true) }
}
{
const f = await fixture()
let reached!: () => void, release!: () => void, reservationCommitted = false, anchorCallbacks = 0
const reachedReservation = new Promise<void>(resolve => { reached = resolve })
const resume = new Promise<void>(resolve => { release = resolve })
const storage = createProbeJournalStorage({ policy: { ...PROBE_STORAGE_LIMITS, retentionDays: 30 }, safetyWindowMs: 60000,
authorize: async () => "c".repeat(64), connect: async () => {
const client = await pool.connect()
return { release: destroy => client.release(destroy), query: async (text, values) => {
const result = await client.query(text, values)
if (text === "COMMIT" && !reservationCommitted) { reservationCommitted = true; reached(); await resume }
return result
} }
} })
const claim = storage.claim(f.scope, { deadlineAt: Date.now() + 120000 }, async () => { anchorCallbacks++; assert.fail("Cancellation won before the anchor could execute") })
.then(() => false, () => true)
const timer = setTimeout(() => { reached(); release() }, 30000)
try {
await reachedReservation; assert(reservationCommitted, "Claimer must actually commit its reservation before the timeout")
assert.equal((await requestStorageDiagnosticCancellation(f.principal, f.operationId)).cancelRequested, true)
const charged = await quota(f.ownerId)
release(); assert.equal(await claim, true); assert.equal(anchorCallbacks, 0)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); assert.deepEqual(await quota(f.ownerId), charged)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM server_probe_executions WHERE receipt_id=$1", [f.receiptId])).rows[0].n, 0)
pass("actual cancellation between reservation COMMIT and claim consumption spends the existing admission and prevents the anchor callback without extra quota")
} finally { clearTimeout(timer); release(); await claim }
}
{
const f = await fixture(true); await activeClaim(f)
const before = await quota(f.ownerId), protectionId = randomUUID()
await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2::jsonb,$3)", [protectionId, JSON.stringify({ kind: "server", id: String(serverId), serverId, protection: "deny" }), f.ownerId])
try {
assert.equal((await requestStorageDiagnosticCancellation(f.principal, f.operationId)).cancelRequested, true)
assert.deepEqual(await counts(f), { cancellations: 1, audits: 1, admissions: 1 }); assert.deepEqual(await quota(f.ownerId), before)
}
finally { await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protectionId]) }
pass("server deny protection preserves the owner's stop capability for an already active claim; no native write and no additional quota charge")
}
console.log(JSON.stringify({ suite: "approved owned diagnostic cancellation host", actualSql: true, productionHostExecuted: true, cases, nativeCalls: 0, apiCalls: 0 }))
} finally { await closePool() }
@@ -0,0 +1,111 @@
/** Approved opt-in source host. Public API registration remains separate.
* Opt-in v2 cancellation host; one backend owns authority, source and quota. */
import { z } from "zod"
import type { PoolClient, QueryConfig } from "pg"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { createProbeJournalStorage, validateProbeStoragePolicy,
type ProbeStorageConnection, type ProbeStoragePolicy } from "../../services/server-probe-journal-maintenance.js"
import { effectiveMcpConnection, type McpPrincipal } from "./store.js"
import { fingerprint, McpError } from "./security.js"
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
import { loadDiagnosticCancellationSource, type DiagnosticCancellationSource } from "./diagnostic-cancellation-source.js"
export const PROBE_STORAGE_LIMITS = Object.freeze({ globalBytes: 256 * 1048576, ownerBytes: 24 * 1048576,
globalFiles: 1024, ownerFiles: 128, globalAdmissions: 8192, ownerAdmissions: 1024 })
export type ProbeStorageLimits = Omit<ProbeStoragePolicy, "retentionDays">
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string().datetime() }).strict()
const deny = (): never => { throw new McpError("STATE_CONFLICT", "Допуск или исходная диагностика изменились", 409) }
function policy(raw: ProbeStorageLimits, retention: unknown) {
return validateProbeStoragePolicy({ retention_days: retention, global_bytes: raw.globalBytes, owner_bytes: raw.ownerBytes,
global_files: raw.globalFiles, owner_files: raw.ownerFiles, global_admissions: raw.globalAdmissions, owner_admissions: raw.ownerAdmissions })
}
/** Limits are trusted deployment configuration, never tool/request JSON.
* Crypto, pool, source loader and settings reader are fixed production code. */
export function createStorageDiagnosticCancellationRequest(limits: ProbeStorageLimits = PROBE_STORAGE_LIMITS) {
const configured = Object.freeze({ ...limits }); policy(configured, 30)
return async (rawPrincipal: McpPrincipal, operationId: string, signal?: AbortSignal) => {
const principal = principalSchema.parse(structuredClone(rawPrincipal)); z.string().uuid().parse(operationId)
const deadlineAt = Math.min(Date.now() + 120000, Date.parse(principal.credentialExpiresAt))
const live = () => { signal?.throwIfAborted(); if (Date.now() >= deadlineAt) return deny() }
live()
const client = await pool.connect()
let released = false, unsafe = false, inTransaction = false
const release = (destroy = false) => { if (!released) { released = true; client.release(unsafe || destroy) } }
const query = async (text: string, values?: unknown[]) => {
live()
const request: QueryConfig & { query_timeout: number } = { text, values, query_timeout: Math.max(1, Math.min(10000, deadlineAt - Date.now())) }
try {
const result = await client.query(request)
if (text === "BEGIN") inTransaction = true
if (text === "COMMIT") { if (result.command !== "COMMIT") throw Error("Cancellation COMMIT not acknowledged"); inTransaction = false }
if (text === "ROLLBACK") inTransaction = false
live(); return result
} catch (error) { unsafe = true; throw error }
}
// Store functions issue fixed string SQL. The raw bounded backend avoids
// recursive authorization and waiting for a second pool slot under locks.
const authoritySql: Pick<PoolClient, "query"> = { query: query as PoolClient["query"] }
const connection: ProbeStorageConnection = { query, release }
try {
const readPolicy = async () => {
const rows = (await query("SELECT enabled,requests_retention_days FROM mcp_settings WHERE id=1")).rows
if (rows.length !== 1 || rows[0].enabled !== true) return deny()
return policy(configured, rows[0].requests_retention_days)
}
const current = async () => {
live()
const access = await effectiveMcpConnection(principal, authoritySql)
const credentials = (await query("SELECT expires_at FROM mcp_credentials WHERE id=$1 AND connection_id=$2 AND revoked_at IS NULL AND expires_at>clock_timestamp()", [principal.credentialId, principal.connectionId])).rows
if (credentials.length !== 1 || access.policy.mode === "observer"
|| !hasPermission(access.owner_permissions, "mm:servers:write") || !hasPermission(access.policy.permissions, "mm:servers:write")
|| !access.policy.scope.operationIds.includes(SERVER_PROBE_OPERATION)) return deny()
live()
const storagePolicy = await readPolicy()
return { access, storagePolicy, proof: fingerprint({ principal, ownerId: access.owner_id, policy: access.policy,
permissions: access.owner_permissions, generation: access.generation, ownerClaims: access.owner_claims_issued_at,
ownerValidUntil: access.owner_valid_until, credentialExpiresAt: credentials[0].expires_at, storagePolicy }) }
}
const initial = await current()
let original: Readonly<DiagnosticCancellationSource> | undefined
const source = async (sql: Parameters<typeof loadDiagnosticCancellationSource>[0]) => {
const value = await loadDiagnosticCancellationSource(sql, operationId, { connectionId: initial.access.id, ownerId: initial.access.owner_id })
if (value.serverIds.some(id => !initial.access.policy.scope.serverIds.includes(id))) return deny()
return value
}
const immutable = (value: Readonly<DiagnosticCancellationSource>) => fingerprint({ scope: value.scope, sourceDigest: value.sourceDigest,
serverIds: value.serverIds, stageBudget: value.stageBudget, credentialId: value.originalCredentialId })
const storage = createProbeJournalStorage({ policy: initial.storagePolicy, safetyWindowMs: 60000,
connect: async () => connection,
authorize: async (ownerId, operation, bound) => {
if (ownerId !== initial.access.owner_id || operation !== "cancel") return deny()
const fresh = await current(); if (fresh.proof !== initial.proof) return deny()
if (bound && (bound.ownerId !== ownerId || original && fingerprint(bound) !== fingerprint(original.scope))) return deny()
// No source locks before the global storage lock. After a known
// COMMIT, a terminal transition cannot erase its durable stop record.
if (original && inTransaction && immutable(await source({ query })) !== immutable(original)) return deny()
return initial.proof
} })
const result = await storage.cancel(initial.access.owner_id, { deadlineAt, signal }, async sql => {
const value = await source(sql)
if (value.terminal) return null
original = value; return value.scope
}, async (sql, bound) => {
const value = await source(sql)
if (!original || value.terminal || immutable(value) !== immutable(original) || fingerprint(value.scope) !== fingerprint(bound)) return deny()
const inserted = (await sql.query("INSERT INTO mcp_diagnostic_cancellations(operation_id,connection_id,actor_id) VALUES($1,$2,$3) ON CONFLICT DO NOTHING RETURNING operation_id", [operationId, initial.access.id, initial.access.owner_id])).rows
if (inserted.length === 1 && inserted[0].operation_id === operationId) {
await sql.query("INSERT INTO mcp_audit(actor_id,action,connection_id,payload) VALUES($1,'diagnostic.cancel-requested',$2,$3::jsonb)", [initial.access.owner_id, initial.access.id, JSON.stringify({ operationId })])
} else {
if (inserted.length !== 0) return deny()
const prior = (await sql.query("SELECT operation_id,connection_id,actor_id FROM mcp_diagnostic_cancellations WHERE operation_id=$1", [operationId])).rows
if (prior.length !== 1 || prior[0].operation_id !== operationId || prior[0].connection_id !== initial.access.id || prior[0].actor_id !== initial.access.owner_id) return deny()
}
return { operationId, cancelRequested: true, terminal: false, automaticReplay: false }
})
return result ?? { operationId, cancelRequested: false, terminal: true, automaticReplay: false }
} finally { release(true) }
}
}
export const requestStorageDiagnosticCancellation = createStorageDiagnosticCancellationRequest()
@@ -0,0 +1,70 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { pool } from "../../db/index.js"
import { McpError } from "./security.js"
import { createDiagnosticCancellationRequest, withDiagnosticCancellation } from "./diagnostic-cancellation.js"
import type { ConnectionRow } from "./store.js"
const oldQuery = pool.query, oldConnect = pool.connect
pool.query = (() => { throw Error("Actual SQL forbidden") }) as never
pool.connect = (() => { throw Error("Actual SQL forbidden") }) as never
let cases = 0
// Production has an application listener/in-flight I/O. This inert signal-only
// test supplies a referenced handle while the watch itself remains unref'ed.
const keepAlive = setInterval(() => {}, 1000)
const context = { operationId: randomUUID(), phase: "forward" as const, stepIndex: 0 }
const sleep = (time: number) => new Promise(resolve => setTimeout(resolve, time))
try {
{
let granted = true, cancelled = false, reads = 0, signal: AbortSignal | undefined
const deps = { intervalMs: 10, authorize: async () => { if (!granted) throw new McpError("PERMISSION_DENIED", "revoked") }, read: async () => { reads++; return { state: "running", operation: "servers.probes.run", cancelled } } }
assert.equal(await withDiagnosticCancellation(context, async value => { signal = value; return "completed" }, deps), "completed")
assert.equal(signal!.aborted, true, "Detached work loses its signal after the service ends"); cases++
cancelled = true
let started = false
await assert.rejects(withDiagnosticCancellation(context, async () => { started = true }, deps), error => error instanceof McpError && error.code === "READ_CANCELLED")
assert.equal(started, false); cases++
cancelled = false
const pending = withDiagnosticCancellation(context, async value => {
cancelled = true
await new Promise<never>((_resolve, reject) => value.addEventListener("abort", () => reject(value.reason), { once: true }))
}, deps)
await assert.rejects(pending, error => error instanceof McpError && error.code === "READ_CANCELLED"); cases++
cancelled = false
await assert.rejects(withDiagnosticCancellation(context, async value => {
granted = false
await new Promise<never>((_resolve, reject) => value.addEventListener("abort", () => reject(value.reason), { once: true }))
}, deps), error => error instanceof McpError && error.code === "PERMISSION_DENIED"); cases++
const count = reads; await sleep(25); assert.equal(reads, count, "No watch continues after the operation ends")
}
{
const deps = { intervalMs: 10, authorize: async () => {}, read: async () => ({ state: "running", operation: "other.operation", cancelled: false }) }
await assert.rejects(withDiagnosticCancellation(context, async () => "unreachable", deps), McpError); cases++
await assert.rejects(withDiagnosticCancellation({ operationId: context.operationId, phase: "recovery" }, async () => "unreachable", deps), McpError); cases++
}
const principal = { connectionId: randomUUID(), credentialId: randomUUID(), credentialExpiresAt: new Date(Date.now() + 300000).toISOString() }
const initial = { id: principal.connectionId, owner_id: "owner", generation: "g", owner_permissions: ["mm:servers:write"], policy: { mode: "approval", permissions: ["mm:servers:write"], scope: { operationIds: ["servers.probes.run"], serverIds: [1, 2] } } } as ConnectionRow
let current = structuredClone(initial), state = "running", actualOperation = "servers.probes.run", ids = [1, 2], evidence = true, inserted = 0, audits = 0, afterLock: (() => void) | undefined
let existing: { operation_id: string; connection_id: string; actor_id: string } | undefined
const client = { async query(sql: string, values: unknown[]) {
if (sql.startsWith("SELECT o.state")) { assert.equal(values[1], principal.connectionId); afterLock?.(); return { rows: [{ state, operation_id: actualOperation, evidence: evidence ? { sealed: "owned-evidence" } : null }] } }
if (sql.startsWith("INSERT INTO mcp_diagnostic")) { inserted++; assert.equal(values[2], "owner"); if (existing) return { rows: [] }; existing = { operation_id: String(values[0]), connection_id: String(values[1]), actor_id: String(values[2]) }; return { rows: [{ operation_id: values[0] }] } }
if (sql.startsWith("SELECT operation_id,connection_id,actor_id")) return { rows: existing ? [structuredClone(existing)] : [] }
throw Error("Unexpected query")
} }
const request = createDiagnosticCancellationRequest({ connection: async () => structuredClone(current), transaction: (async (work: (value: unknown) => Promise<unknown>) => work(client)) as never, open: async () => JSON.stringify({ target: { scope: "servers", serverIds: ids } }), audit: async () => { audits++ } })
assert.deepEqual(await request(principal, context.operationId), { operationId: context.operationId, cancelRequested: true, terminal: false, automaticReplay: false }); assert.equal(inserted, 1); assert.equal(audits, 1); cases++
assert.equal((await request(principal, context.operationId)).cancelRequested, true); assert.equal(audits, 1); cases++
inserted = 1
existing!.actor_id = "foreign"
await assert.rejects(request(principal, context.operationId), error => error instanceof McpError && error.code === "STATE_CONFLICT"); assert.equal(audits, 1); cases++
existing!.actor_id = "owner"; inserted = 1
state = "applied"; await request(principal, context.operationId); assert.equal(inserted, 1); cases++
state = "running"; ids = [1, 3]; await assert.rejects(request(principal, context.operationId), McpError); assert.equal(inserted, 1); cases++
ids = [1, 2]; actualOperation = "users.delete"; await assert.rejects(request(principal, context.operationId), McpError); cases++
actualOperation = "servers.probes.run"; evidence = false; await assert.rejects(request(principal, context.operationId), McpError); cases++
evidence = true; afterLock = () => { current.owner_permissions = [] }; await assert.rejects(request(principal, context.operationId), McpError); assert.equal(inserted, 1); cases++
afterLock = undefined; current = structuredClone(initial); current.policy.scope.operationIds = []; await assert.rejects(request(principal, context.operationId), McpError); cases++
current = structuredClone(initial); current.policy.mode = "observer"; await assert.rejects(request(principal, context.operationId), error => error instanceof McpError && error.code === "PERMISSION_DENIED"); assert.equal(inserted, 1); cases++
console.log(`Diagnostic cancellation: ${cases} inert cases passed; owned full-scope durable requests, active AbortSignal, late revoke/cancel, observer refusal, terminal/no inverse and watch teardown; no actual SQL`)
} finally { clearInterval(keepAlive); pool.query = oldQuery; pool.connect = oldConnect }
@@ -0,0 +1,99 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
import { effectiveMcpConnection, transaction, audit, type McpPrincipal } from "./store.js"
import { McpError, fingerprint, decryptSecret } from "./security.js"
import type { McpServiceExecutionContext } from "./service-registry.js"
const uuid = z.string().uuid()
const stop = (code: string, message: string, status = 409): never => { throw new McpError(code, message, status) }
interface RunningDiagnostic { state: string; operation: string; cancelled: boolean }
const productionWatch = {
authorize: assertVerifiedConfigurationAccess,
intervalMs: 1000,
async read(operationId: string): Promise<RunningDiagnostic | undefined> {
const client = await pool.connect(); let completed = false
try {
await client.query("BEGIN READ ONLY")
await client.query("SET LOCAL statement_timeout='2000ms'")
const rows = (await client.query("SELECT o.state,p.operation_id AS operation,EXISTS(SELECT 1 FROM mcp_diagnostic_cancellations c WHERE c.operation_id=o.id) AS cancelled FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id WHERE o.id=$1 LIMIT 2", [operationId])).rows
await client.query("COMMIT"); completed = true
return rows.length === 1 ? rows[0] as RunningDiagnostic : undefined
} finally { client.release(!completed) }
},
}
export type DiagnosticCancellationWatch = typeof productionWatch
/** Only explicitly cancellable diagnostics use this lane. The signal follows
* durable cancellation across replicas; pending work must honor AbortSignal.
* A cancellation never executes an inverse or repeats a measurement. */
export async function withDiagnosticCancellation<T>(context: McpServiceExecutionContext, work: (signal: AbortSignal) => Promise<T>, overrides: Partial<DiagnosticCancellationWatch> = {}): Promise<T> {
if (context.phase !== "forward" || !uuid.safeParse(context.operationId).success || !Number.isSafeInteger(context.stepIndex) || context.stepIndex < 0) return stop("INVALID_INPUT", "Диагностика требует исходный контекст операции", 400)
const deps = { ...productionWatch, ...overrides }
if (!Number.isSafeInteger(deps.intervalMs) || deps.intervalMs < 10 || deps.intervalMs > 5000) return stop("INVALID_INPUT", "Некорректный интервал отмены", 400)
const controller = new AbortController()
let active = true, polling: Promise<void> | undefined, timer: ReturnType<typeof setInterval> | undefined
const check = async () => {
controller.signal.throwIfAborted()
await deps.authorize()
const state = await deps.read(context.operationId)
await deps.authorize()
if (!state || state.operation !== SERVER_PROBE_OPERATION || !["pending", "running"].includes(state.state)) return stop("STATE_CONFLICT", "Диагностика больше не выполняется")
if (state.cancelled) return stop("READ_CANCELLED", "Запрошена отмена диагностики")
controller.signal.throwIfAborted()
}
try {
await check()
timer = setInterval(() => {
if (!active || polling) return
polling = check().catch(error => {
if (active) controller.abort(error instanceof McpError ? error : new McpError("STATE_UNAVAILABLE", "Состояние отмены диагностики недоступно", 503))
}).finally(() => { polling = undefined })
}, deps.intervalMs)
timer.unref()
const result = await work(controller.signal)
await check()
return result
} finally {
active = false
if (timer) clearInterval(timer)
if (polling) await polling
controller.abort(new McpError("READ_CANCELLED", "Контекст диагностики завершён", 409))
}
}
const productionRequest = { connection: effectiveMcpConnection, transaction, open: decryptSecret, audit }
export function createDiagnosticCancellationRequest(overrides: Partial<typeof productionRequest> = {}) {
const deps = { ...productionRequest, ...overrides }
return async (principal: McpPrincipal, operationId: string) => {
uuid.parse(operationId)
const initial = await deps.connection(principal)
if (initial.policy.mode === "observer" || !hasPermission(initial.owner_permissions, "mm:servers:write") || !hasPermission(initial.policy.permissions, "mm:servers:write") || !initial.policy.scope.operationIds.includes(SERVER_PROBE_OPERATION)) return stop("PERMISSION_DENIED", "Нет права отмены серверной диагностики", 403)
return deps.transaction(async client => {
const current = await deps.connection(principal, client)
if (fingerprint(current.policy) !== fingerprint(initial.policy) || fingerprint(current.owner_permissions) !== fingerprint(initial.owner_permissions) || current.owner_id !== initial.owner_id || current.generation !== initial.generation) return stop("STATE_CONFLICT", "Допуск отмены изменился")
const rows = (await client.query("SELECT o.state,p.operation_id,CASE WHEN octet_length(p.evidence::text)<=262144 THEN p.evidence ELSE NULL END AS evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id WHERE o.id=$1 AND o.connection_id=$2 FOR UPDATE OF o", [operationId, principal.connectionId])).rows
if (rows.length !== 1 || rows[0].operation_id !== SERVER_PROBE_OPERATION) return stop("NOT_FOUND", "Диагностика не найдена", 404)
const raw = rows[0].evidence?.sealed
if (typeof raw !== "string" || raw.length > 262144) return stop("STATE_UNAVAILABLE", "Область диагностики не подтверждена", 503)
let target: { scope: "servers"; serverIds: number[] }
try { target = z.object({ scope: z.literal("servers"), serverIds: z.array(z.number().int().positive().safe()).min(1).max(2) }).strict().parse(JSON.parse(await deps.open(raw)).target) } catch { return stop("STATE_UNAVAILABLE", "Область диагностики не подтверждена", 503) }
if (new Set(target.serverIds).size !== target.serverIds.length || target.serverIds.some(id => !current.policy.scope.serverIds.includes(id))) return stop("SERVER_DENIED", "Диагностика находится вне текущей области", 403)
if (!["pending", "running"].includes(rows[0].state)) return { operationId, cancelRequested: false, terminal: true, automaticReplay: false }
const fresh = await deps.connection(principal, client)
if (fingerprint(fresh.policy) !== fingerprint(current.policy) || fingerprint(fresh.owner_permissions) !== fingerprint(current.owner_permissions) || fresh.owner_id !== current.owner_id || fresh.generation !== current.generation) return stop("STATE_CONFLICT", "Допуск отмены изменился перед записью")
const inserted = await client.query("INSERT INTO mcp_diagnostic_cancellations(operation_id,connection_id,actor_id) VALUES($1,$2,$3) ON CONFLICT DO NOTHING RETURNING operation_id", [operationId, current.id, current.owner_id])
if (inserted.rows.length === 1 && inserted.rows[0].operation_id === operationId) {
await deps.audit(client, current.owner_id, "diagnostic.cancel-requested", current.id, { operationId })
} else {
if (inserted.rows.length !== 0) return stop("STATE_CONFLICT", "Запись отмены не подтверждена")
const prior = (await client.query("SELECT operation_id,connection_id,actor_id FROM mcp_diagnostic_cancellations WHERE operation_id=$1", [operationId])).rows
if (prior.length !== 1 || prior[0].operation_id !== operationId || prior[0].connection_id !== current.id || prior[0].actor_id !== current.owner_id) return stop("STATE_CONFLICT", "Исходная отмена принадлежит другому допуску")
}
return { operationId, cancelRequested: true, terminal: false, automaticReplay: false }
})
}
}
export const requestDiagnosticCancellation = createDiagnosticCancellationRequest()
@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { analyzeEcmpPreflight, preflightObservation, preflightPaths, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js"
function fixture(): Record<PreflightSource, PreflightObservation> {
const result = Object.fromEntries(Object.keys(preflightPaths("ipv4")).map(key => [key, { rows: [], error: null }])) as Record<PreflightSource, PreflightObservation>
result.routes.rows = [1, 2].map(index => ({ ".id": `*${index}`, "dst-address": "192.0.2.0/24", "routing-table": "main", gateway: `192.0.2.${index}`, "immediate-gw": `192.0.2.${index}%gre${index}`, disabled: "false", active: index === 1 ? "true" : "false", distance: String(index * 10) }))
result.settings.rows = [{ "rp-filter": "no", "ipv4-multipath-hash-policy": "l3", "multipath-hash-policy": "l4" }]
result.connectionTracking.rows = [{ enabled: "auto" }]
return result
}
test("preflight observes unequal/inactive candidates without claiming packet/path acceptance", () => {
const result = analyzeEcmpPreflight("ipv4", ["*1", "*2"], fixture())
assert.equal(result.configurationObserved, true); assert.equal(result.routeSummary[1].active, false)
assert.equal(result.decision, "review-required"); assert.equal(result.independentPaths, "not-proven"); assert.equal(result.networkValidation, "not-performed")
assert.equal(result.hashPolicy, "l3"); assert.deepEqual(result.findings, [])
})
test("NAT, FastTrack, marks, notrack, strict RPF and shared egress require explicit review", () => {
const sources = fixture()
sources.filter.rows = [{ ".id": "*fast", action: "fasttrack-connection", disabled: "false" }, { ".id": "*disabled", action: "fasttrack-connection", disabled: "true" }]
sources.nat.rows = [{ ".id": "*nat", action: "masquerade", disabled: "false" }]
sources.mangle.rows = [{ ".id": "*mark", action: "mark-routing", disabled: "false" }]
sources.raw.rows = [{ ".id": "*notrack", action: "notrack", disabled: "false" }]
sources.settings.rows[0]["rp-filter"] = "strict"
sources.connectionTracking.rows[0].enabled = "no"
sources.routes.rows[1]["immediate-gw"] = "192.0.2.2%gre1"
const result = analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources)
for (const code of ["NAT_AFFINITY_REVIEW", "FASTTRACK_REVIEW", "POLICY_ROUTING_REVIEW", "NOTRACK_REVIEW", "STRICT_RPF_REVIEW", "SHARED_EGRESS_INTERFACE", "CONNECTION_TRACKING_DISABLED"]) assert.ok(result.findings.some(item => item.code === code), code)
assert.deepEqual(result.findings.find(item => item.code === "FASTTRACK_REVIEW")?.ids, ["*fast"])
})
test("failed/malformed/omitted evidence is unknown, never an empty healthy catalog", () => {
for (const raw of [null, {}, [null], [{ ".id": "*1" }, { ".id": "*1" }], [{ name: "missing-id" }]]) assert.ok(preflightObservation(raw).error)
assert.ok(preflightObservation([], true).error); assert.ok(preflightObservation([{}, {}], true).error)
const sources = fixture()
sources.nat = { rows: [], error: "unavailable" }; delete sources.settings.rows[0]["ipv4-multipath-hash-policy"]
sources.raw.rows = [{ ".id": "*raw", action: "notrack" }]
const result = analyzeEcmpPreflight("ipv4", ["*1", "*missing"], sources)
assert.equal(result.configurationObserved, false)
for (const code of ["OBSERVATION_UNAVAILABLE", "ROUTE_NOT_OBSERVED", "HASH_POLICY_UNKNOWN", "RULE_ENABLEMENT_UNKNOWN", "NOTRACK_REVIEW"]) assert.ok(result.findings.some(item => item.code === code), code)
assert.equal(result.sources.nat.observed, false)
const absentGateways = fixture()
absentGateways.routes.rows.forEach(row => { delete row.gateway })
assert.ok(!analyzeEcmpPreflight("ipv4", ["*1", "*2"], absentGateways).findings.some(item => item.code === "DUPLICATE_GATEWAY"), "Two unknown gateways do not prove duplication")
const malformedSettings = fixture()
malformedSettings.settings.rows = [{ "ipv4-multipath-hash-policy": { toString: null }, "rp-filter": 0 }]
assert.equal(analyzeEcmpPreflight("ipv4", ["*1", "*2"], malformedSettings).configurationObserved, false)
})
test("IPv6 uses IPv6 paths/hash policy and explicitly has no IPv4 RPF setting", () => {
assert.equal(preflightPaths("ipv6").nat, "/ipv6/firewall/nat")
assert.equal(preflightPaths("ipv6").connectionTracking, "/ip/firewall/connection/tracking")
const result = analyzeEcmpPreflight("ipv6", ["*1", "*2"], fixture())
assert.deepEqual(result.rpf, { supported: false, mode: null }); assert.equal(result.hashPolicy, "l4")
assert.ok(!result.findings.some(item => item.code.startsWith("RPF")))
})
+63
View File
@@ -0,0 +1,63 @@
type Row = Record<string, unknown>
export type PreflightObservation = { rows: Row[]; error: string | null }
export const preflightPaths = (family: "ipv4" | "ipv6") => {
const base = family === "ipv4" ? "ip" : "ipv6"
return { routes: `/${base}/route`, settings: `/${base}/settings`, filter: `/${base}/firewall/filter`, nat: `/${base}/firewall/nat`, mangle: `/${base}/firewall/mangle`, raw: `/${base}/firewall/raw`, rules: "/routing/rule", tables: "/routing/table", vrfs: "/ip/vrf", connectionTracking: "/ip/firewall/connection/tracking" }
}
export type PreflightSource = keyof ReturnType<typeof preflightPaths>
export function preflightObservation(raw: unknown, singleton = false): PreflightObservation {
const rows = singleton && !Array.isArray(raw) && raw && typeof raw === "object" ? [raw] : raw
if (!Array.isArray(rows) || singleton && rows.length !== 1 || rows.some(row => !row || typeof row !== "object" || Array.isArray(row))) return { rows: [], error: "Некорректный ответ устройства" }
if (!singleton) {
const ids = rows.map(row => row[".id"])
if (ids.some(id => typeof id !== "string" || !id.trim()) || new Set(ids).size !== ids.length) return { rows: [], error: "Неоднозначные ID наблюдаемого каталога" }
}
return { rows, error: null }
}
const text = (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null
const flag = (value: unknown) => value === true || value === "true" || value === "yes" ? true : value === false || value === "false" || value === "no" ? false : null
/** Configuration evidence only. Rule presence does not prove packet matching,
* physical independence, NAT affinity, return path or traffic distribution. */
export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record<PreflightSource, PreflightObservation>) {
const findings: { code: string; level: "review" | "unknown"; resource: string; ids: string[]; objectCount: number; idsTruncated: boolean; message: string }[] = []
const add = (code: string, level: "review" | "unknown", resource: string, ids: string[], message: string) => findings.push({ code, level, resource, ids: ids.slice(0, 100), objectCount: ids.length, idsTruncated: ids.length > 100, message })
for (const [resource, observation] of Object.entries(observed)) if (observation.error) add("OBSERVATION_UNAVAILABLE", "unknown", resource, [], observation.error)
const candidates = routeIds.map(id => observed.routes.rows.find(row => row[".id"] === id))
const selected = candidates.filter((row): row is Row => !!row)
if (selected.length !== routeIds.length) add("ROUTE_NOT_OBSERVED", "unknown", "routes", routeIds.filter((_id, index) => !candidates[index]), "Не все выбранные маршруты подтверждены")
const routeSummary = selected.map(row => ({ id: String(row[".id"]), prefix: text(row["dst-address"]), table: text(row["routing-table"]), gateway: text(row.gateway), immediateGateway: text(row["immediate-gw"]), active: flag(row.active), disabled: flag(row.disabled), distance: text(row.distance) }))
if (routeSummary.some(row => !row.prefix || !row.table || !row.gateway || !row.immediateGateway || row.disabled === null)) add("ROUTE_CONTEXT_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждены prefix/table/gateway/disabled; отсутствие свойства не считается значением по умолчанию")
if (routeSummary.some(row => row.disabled === true)) add("DISABLED_ROUTE", "review", "routes", routeSummary.filter(row => row.disabled === true).map(row => row.id), "В наборе есть отключённые маршруты")
if (new Set(routeSummary.map(row => row.prefix).filter(Boolean)).size > 1 || new Set(routeSummary.map(row => row.table).filter(Boolean)).size > 1) add("DIFFERENT_ROUTE_CONTEXTS", "review", "routes", routeSummary.map(row => row.id), "Prefix или routing table выбранных маршрутов различаются")
const knownGateways = routeSummary.map(row => row.gateway).filter(Boolean)
if (new Set(knownGateways).size !== knownGateways.length) add("DUPLICATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Next-hop выбранных маршрутов повторяется")
const interfaces = routeSummary.map(row => row.immediateGateway?.split("%")[1]?.split("@")[0] || null)
if (interfaces.some(iface => iface === null)) add("EGRESS_INTERFACE_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждён интерфейс immediate gateway")
else if (new Set(interfaces).size < interfaces.length) add("SHARED_EGRESS_INTERFACE", "review", "routes", routeSummary.map(row => row.id), "Несколько next-hop используют один интерфейс; независимость путей не подтверждена")
const maybeEnabled = (resource: PreflightSource) => {
const rows = observed[resource].rows.filter(row => flag(row.disabled) !== true)
const unknown = rows.filter(row => flag(row.disabled) === null)
if (unknown.length) add("RULE_ENABLEMENT_UNKNOWN", "unknown", resource, unknown.map(row => String(row[".id"])), "Для части объектов не подтверждён disabled; они остаются в проверяемом наборе")
return rows
}
const fastTrack = maybeEnabled("filter").filter(row => row.action === "fasttrack-connection")
const nat = maybeEnabled("nat"), mangle = maybeEnabled("mangle")
const raw = maybeEnabled("raw").filter(row => row.action === "notrack" || row.action === "jump"), rules = maybeEnabled("rules")
if (fastTrack.length) add("FASTTRACK_REVIEW", "review", "filter", fastTrack.map(row => String(row[".id"])), "Обнаружен FastTrack: проверить исключение трафика вне main и влияние на наблюдаемость")
if (nat.length) add("NAT_AFFINITY_REVIEW", "review", "nat", nat.map(row => String(row[".id"])), "Проверить NAT, привязку соединений к выходу и обратный путь; совпадение пакетов не вычислялось")
if (mangle.length || rules.length) add("POLICY_ROUTING_REVIEW", "review", "routing", [...mangle, ...rules].map(row => String(row[".id"])), "Обнаружены маркировки/переходы или routing rules; проверить фактическую таблицу и асимметрию")
if (raw.length) add("NOTRACK_REVIEW", "review", "raw", raw.map(row => String(row[".id"])), "Проверить notrack и переходы raw относительно NAT/stateful firewall")
const settings = observed.settings.rows[0] ?? {}, hashKey = family === "ipv4" ? "ipv4-multipath-hash-policy" : "multipath-hash-policy"
const hashValue = text(settings[hashKey]), hashPolicy = hashValue && ["l3", "l4", "l3-inner"].includes(hashValue) ? hashValue : null
if (!hashPolicy) add("HASH_POLICY_UNKNOWN", "unknown", "settings", [], "Устройство не подтвердило hash policy")
const rpfValue = text(settings["rp-filter"]), rpf = family === "ipv4" && rpfValue && ["no", "loose", "strict"].includes(rpfValue) ? rpfValue : null
if (family === "ipv4" && !rpf) add("RPF_UNKNOWN", "unknown", "settings", [], "Режим IPv4 RPF не подтверждён")
if (rpf === "strict") add("STRICT_RPF_REVIEW", "review", "settings", [], "Strict RPF требует проверки обратного пути и совместимости с таблицами/асимметрией")
const trackingRaw = observed.connectionTracking.rows[0]?.enabled, trackingValue = typeof trackingRaw === "boolean" ? String(trackingRaw) : text(trackingRaw)
const connectionTracking = trackingValue && ["auto", "yes", "no", "true", "false"].includes(trackingValue) ? trackingValue : null
if (!connectionTracking) add("CONNECTION_TRACKING_UNKNOWN", "unknown", "connectionTracking", [], "Режим connection tracking не подтверждён")
else if (connectionTracking === "no" || connectionTracking === "false") add("CONNECTION_TRACKING_DISABLED", "review", "connectionTracking", [], "Connection tracking отключён; проверить зависимости NAT/stateful firewall")
return { family, decision: "review-required", configurationObserved: !findings.some(item => item.level === "unknown"), networkValidation: "not-performed", independentPaths: "not-proven", routeSummary, hashPolicy, rpf: { supported: family === "ipv4", mode: rpf }, connectionTracking,
sources: Object.fromEntries(Object.entries(observed).map(([key, value]) => [key, { observed: value.error === null, count: value.rows.length }])), findings }
}
@@ -0,0 +1,89 @@
/** Component test: actual store validation/parser with fake SQL rows and an
* isolated real generation/revocation filesystem. No PostgreSQL or network. */
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { mkdtemp, rm } from "node:fs/promises"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { test } from "node:test"
import type { PoolClient } from "pg"
import { mcpConnectionPolicySchema } from "@mmapp/contracts/mcp"
import { pool, closePool } from "../../db/index.js"
import { accessGeneration, fingerprint } from "./security.js"
import { effectiveMcpConnection, effectiveMcpConnectionSource, type ConnectionRow, type McpPrincipal } from "./store.js"
test("effective connection source reuses precisely the grant rows it validates; fake SQL component only", async t => {
const state = await mkdtemp(join(fileURLToPath(new URL("../../../../tmp/", import.meta.url)), "mm-mcp-grant-source-"))
const previousState = process.env.MCP_STATE_DIR, originalQuery = pool.query, originalConnect = pool.connect
process.env.MCP_STATE_DIR = state
pool.query = (() => { throw Error("Component must not use default SQL") }) as never
pool.connect = (() => { throw Error("Component must not acquire a backend") }) as never
try {
const generation = await accessGeneration(), now = Date.now()
const principal: McpPrincipal = { connectionId: randomUUID(), credentialId: randomUUID(), credentialExpiresAt: new Date(now + 60000).toISOString() }
const connection: ConnectionRow = { id: principal.connectionId, name: "component", owner_id: "owner", owner_permissions: ["mm:servers:write"], generation,
created_at: new Date(now), updated_at: new Date(now), last_used_at: null, owner_valid_until: new Date(now + 60000), owner_claims_issued_at: new Date(now - 1000),
policy: mcpConnectionPolicySchema.parse({ id: principal.connectionId, version: 1, status: "active", mode: "approval", dangerousPolicy: "approval", expiresAt: principal.credentialExpiresAt,
permissions: ["mm:servers:write"], scope: { serverIds: [1, 2], operationIds: ["servers.poll", "operations.global.reconcile"], allowGlobal: true },
limits: { callsPerMinute: 100, mutationsPerHour: 100, serversPerPlan: 100, objectsPerPlan: 100 } }) }
const grant = () => ({ payload: { credentialId: principal.credentialId, id: "source-grant", startsAt: new Date(now - 1000).toISOString(), expiresAt: new Date(now + 30000).toISOString(),
scope: { serverIds: [1], operationIds: ["servers.poll"], allowGlobal: false } } })
let rows: { payload: Record<string, unknown> }[] = [], active = true
const commands: string[] = []
const client = { async query(text: string, values: unknown[]) {
commands.push(text)
if (text.startsWith("/* mcp-principal:coherent-source-v1 */")) {
assert.deepEqual(values.slice(0, 2), [principal.credentialId, principal.connectionId])
const utc = (value: Date) => value.toISOString().replace(/(\.\d{3})Z$/, "$1000Z")
const core = { ...structuredClone(connection), __ps_exists: true, __ps_oversize: false, __ps_bytes: 4096, __ps_guard: true, __ps_isolation: "read committed",
__ps_credential: active ? principal.credentialId : null, __ps_credential_current: active, __ps_credential_expires: active ? utc(new Date(principal.credentialExpiresAt)) : null,
__ps_credential_revoked: null, __ps_owner_until: utc(connection.owner_valid_until), __ps_owner_issued: utc(connection.owner_claims_issued_at),
__ps_cutoff_owner: null, __ps_cutoff: null, __ps_cutoff_text: null, __ps_settings: 1, __ps_enabled: true, __ps_policy_sha256: fingerprint(connection.policy) }
return { rows: values[2] && rows.length ? rows.map((row, index) => ({ ...core, __ps_grant_id: "grant-row-" + index, __ps_grant_oversize: false, __ps_grant_payload: structuredClone(row.payload), __ps_grant_sha256: fingerprint(row.payload) }))
: [{ ...core, __ps_grant_id: null, __ps_grant_oversize: null, __ps_grant_payload: null, __ps_grant_sha256: null }] }
}
throw Error("Unexpected component SQL")
} } as unknown as Pick<PoolClient, "query">
const assertOneRead = (start: number) => {
const calls = commands.slice(start)
assert.equal(calls.length, 2)
assert(calls.every(text => text.startsWith("/* mcp-principal:coherent-source-v1 */")))
assert.match(calls.at(-1)!, /ORDER BY id LIMIT 2/)
}
await t.test("no grant retains ordinary connection; public delegate returns identical DTO", async () => {
let start = commands.length
const source = await effectiveMcpConnectionSource(principal, client)
assert.deepEqual(source.grantRows, []); assert.deepEqual(source.connection, connection); assertOneRead(start)
start = commands.length
assert.deepEqual(await effectiveMcpConnection(principal, client), source.connection); assertOneRead(start)
})
await t.test("single source preserves full raw descriptor while intersecting all scope dimensions", async () => {
rows = [grant()]; const start = commands.length
const source = await effectiveMcpConnectionSource(principal, client)
assert.deepEqual(source.grantRows, rows)
assert.deepEqual(source.connection.policy.scope, { serverIds: [1], operationIds: ["servers.poll"], allowGlobal: false })
assert.deepEqual(connection.policy.scope.serverIds, [1, 2]); assertOneRead(start)
})
await t.test("expired/future and duplicate source refuse, with exactly two coherent source queries", async () => {
for (const candidate of [
[{ payload: { ...grant().payload, startsAt: new Date(now - 2000).toISOString(), expiresAt: new Date(now - 1000).toISOString() } }],
[{ payload: { ...grant().payload, startsAt: new Date(now + 10000).toISOString() } }], [grant(), grant()],
]) {
rows = candidate; const start = commands.length
await assert.rejects(effectiveMcpConnectionSource(principal, client), { code: "GRANT_INACTIVE" }); assertOneRead(start)
}
})
await t.test("no source read survives revoked principal and no source result is cached", async () => {
rows = [grant()]; active = false; const start = commands.length
await assert.rejects(effectiveMcpConnectionSource(principal, client), { code: "UNAUTHORIZED" })
assert.equal(commands.slice(start).some(text => text.startsWith("SELECT payload")), false)
active = true; rows = []
assert.deepEqual((await effectiveMcpConnectionSource(principal, client)).grantRows, [])
})
} finally {
pool.query = originalQuery; pool.connect = originalConnect
if (previousState === undefined) delete process.env.MCP_STATE_DIR
else process.env.MCP_STATE_DIR = previousState
await closePool(); await rm(state, { recursive: true, force: true })
}
})
@@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto"
import { z } from "zod"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { appendClientEventRows, prepareEventRows } from "../events/service/events-service.js"
import type { EventInsertInput } from "../events/repository/events-repository.js"
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
import { effectiveMcpConnection } from "./store.js"
import { accessGeneration, encryptSecret, decryptSecret, fingerprint, privateSecretFingerprint, McpError, redact } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import { eventsBatchSchema, assertEventBatchBudget } from "./events-batch-model.js"
import type { add } from "./actions.js"
const operation = "events.batch.append"
const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict()
const conflict = () => new McpError("STATE_CONFLICT", "Пакет событий или область доступа изменились", 409)
async function catalog() {
const rows = (await pool.query("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows
if (rows.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог зависимостей журнала превышает лимит", 409)
return rows.map(row => Number(row.id))
}
async function sourceProof(ids: number[]) { return privateSecretFingerprint(fingerprint(ids), "events-batch-catalog-v1") }
async function access(input: Record<string, unknown>, verified = false) {
if (verified) await assertVerifiedConfigurationAccess()
const principal = principalSchema.parse(input.principal), connection = await effectiveMcpConnection(principal)
if (connection.owner_id !== input.ownerId || connection.policy.mode === "observer" || !hasPermission(connection.owner_permissions, "mm:dashboard:read") || !hasPermission(connection.policy.permissions, "mm:dashboard:read")) throw new McpError("PERMISSION_DENIED", "Допуск к записи событий отозван", 403)
if (fingerprint(connection.policy) !== input.policyProof || connection.generation !== input.connectionGeneration || await accessGeneration() !== input.generation) throw conflict()
const ids = await catalog()
if (!connection.policy.scope.allowGlobal || ids.some(id => !connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальный журнал требует полного каталога серверов", 403)
if (input.catalogProof !== await sourceProof(ids)) throw conflict()
return { principal, ids }
}
async function batch(input: Record<string, unknown>) {
const rows = JSON.parse(await decryptSecret(String(input.batchCiphertext))) as EventInsertInput[]
assertEventBatchBudget(rows)
if (!Array.isArray(rows) || !rows.length || rows.length > 200 || new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => !z.string().uuid().safeParse(row.id).success)) throw conflict()
if (input.batchProof !== await privateSecretFingerprint(fingerprint({ rows, receiptId: input.receiptId, principal: input.principal, ownerId: input.ownerId }), "events-batch-request-v1")) throw conflict()
return rows
}
async function observed(rows: EventInsertInput[]) {
const budget = (await pool.query(`WITH chosen AS MATERIALIZED (SELECT * FROM events WHERE id=ANY($1::text[]) ORDER BY id LIMIT 201),
limits AS (SELECT count(*) AS n,coalesce(sum(octet_length(row_to_json(chosen)::text)),0) AS bytes FROM chosen)
SELECT n>200 OR bytes>131072 AS overflow, CASE WHEN n<=200 AND bytes<=131072
THEN (SELECT coalesce(jsonb_agg(to_jsonb(chosen) ORDER BY id),'[]') FROM chosen) END AS items FROM limits`, [rows.map(row => row.id)])).rows[0]
if (!budget || budget.overflow || !Array.isArray(budget.items)) throw new McpError("LIMIT_EXCEEDED", "Сохранённые события превышают бюджет", 409)
return budget.items.map((row: Record<string, unknown>) => ({ id: row.id, createdAt: new Date(String(row.created_at)).toISOString(), level: row.level, eventType: row.event_type, sourceModule: row.source_module, title: row.title, message: row.message,
entityType: row.entity_type ?? null, entityId: row.entity_id ?? null, payload: row.payload_json ?? {} }))
}
const canonical = (rows: EventInsertInput[]) => rows.map(row => ({ ...row, createdAt: new Date(row.createdAt).toISOString(), entityType: row.entityType ?? null, entityId: row.entityId ?? null, payload: row.payload ?? {} })).sort((a, b) => a.id.localeCompare(b.id))
async function matches(input: Record<string, unknown>) {
const rows = await batch(input), saved = await observed(rows)
return { original: saved.length === 0, desired: fingerprint(saved) === fingerprint(canonical(rows)) }
}
const resultFor = (input: Record<string, unknown>, rows: EventInsertInput[]) => ({ receiptId: input.receiptId, inserted: rows.length, eventIds: rows.map(row => row.id), journalEntriesOnly: true, actionsExecuted: false })
export function registerEventsBatchActions(register: typeof add) {
registerMcpService(operation, {
current: async input => { await access(input); const state = await matches(input); if (!state.original) throw conflict(); return { catalogProof: input.catalogProof, batchAbsent: true } },
execute: async input => {
await access(input, true); const rows = await batch(input)
await appendClientEventRows(rows, async () => { await access(input, true); if (!(await matches(input)).original) throw conflict(); await access(input, true) })
await access(input, true)
if (!(await matches(input)).desired) throw new McpError("VERIFICATION_FAILED", "Запись пакета событий не подтверждена", 409)
return resultFor(input, rows)
},
verify: async (input, result) => { await access(input); return fingerprint(result) === fingerprint(resultFor(input, await batch(input))) && (await matches(input)).desired },
reconcile: async (input, authorize) => {
await authorize(); await access(input); const state = await matches(input)
const result = resultFor(input, await batch(input))
const checkpoints = (await pool.query(`SELECT s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id
JOIN mcp_plans p ON p.id=o.plan_id WHERE o.connection_id=$1 AND p.operation_id=$2 AND s.state='verified'
AND s.intended_state=$3::jsonb AND s.result=$4::jsonb LIMIT 2`, [principalSchema.parse(input.principal).connectionId, operation, JSON.stringify(redact({ kind: "service", service: operation, input })), JSON.stringify(result)])).rows
await authorize(); await access(input)
return { matchesOriginalState: state.original, matchesDesiredState: state.desired && checkpoints.length === 1, configurationMatchesDesired: state.desired }
},
})
register(operation, "mm_events_batch_append_plan", "Добавить пакет записей в журнал событий приложения", "dashboard", "mm:dashboard:read", "write", "global", eventsBatchSchema, {
prepare: async (input, context) => {
context.signal?.throwIfAborted()
try { assertEventBatchBudget(input) } catch { throw new McpError("INVALID_INPUT", "Пакет событий превышает JSON-бюджет 64 КиБ/12 уровней", 400) }
const request = eventsBatchSchema.parse(input), rows = JSON.parse(JSON.stringify(prepareEventRows(request.events))) as EventInsertInput[]
for (const row of rows) { if (!Number.isFinite(Date.parse(row.createdAt))) throw new McpError("INVALID_INPUT", "Время события не распознано", 400); row.createdAt = new Date(row.createdAt).toISOString() }
try { assertEventBatchBudget(rows) } catch { throw new McpError("INVALID_INPUT", "Нормализованный пакет событий превышает JSON-бюджет", 400) }
const ids = await catalog(), catalogProof = await sourceProof(ids), receiptId = randomUUID()
const sealed: Record<string, unknown> = { principal: context.principal, ownerId: context.connection.owner_id, policyProof: fingerprint(context.connection.policy), connectionGeneration: context.connection.generation, generation: await accessGeneration(), catalogProof, receiptId, batchCiphertext: await encryptSecret(JSON.stringify(rows)) }
sealed.batchProof = await privateSecretFingerprint(fingerprint({ rows, receiptId, principal: context.principal, ownerId: context.connection.owner_id }), "events-batch-request-v1")
await access(sealed); context.signal?.throwIfAborted()
return { target: { scope: "global" }, objects: [{ kind: "events-journal", id: "global" }, ...rows.map(row => ({ kind: "event", id: row.id })), ...ids.map(serverId => ({ kind: "server", id: String(serverId), serverId }))], snapshots: [], before: { catalogProof, batchAbsent: true }, requiresHuman: true, mutationCost: 1,
steps: [{ kind: "service", service: operation, input: sealed }], warnings: ["Будет добавлено записей: " + rows.length + ". Подтвердите содержимое пакета во входных данных плана.", "Это записи клиентского журнала, а не доказательства выполнения системных действий. sourceModule/eventType/entity/payload сохраняются как предоставленные данные.", "Пакет вставляется атомарно с серверными UUID. Неизвестный результат не повторяется; для успешной сверки нужен точный VERIFIED checkpoint."] }
},
})
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { eventsBatchSchema, assertEventBatchBudget } from "./events-batch-model.js"
import { prepareEventRows } from "../events/service/events-service.js"
const event = { level: "warning", eventType: " client.note ", sourceModule: "system", title: " title ", message: " message ", entityType: " server ", entityId: " 1 ", payload: { known: 0, nested: { flag: false }, empty: null }, createdAt: "2026-10-24T00:00:00Z" }
test("event batch preserves all REST fields and normalizes once with unique server UUIDs", () => {
const input = eventsBatchSchema.parse({ events: [event, { ...event, entityId: undefined }] })
const rows = prepareEventRows(input.events)
assert.equal(new Set(rows.map(row => row.id)).size, 2)
assert.deepEqual({ ...rows[0], id: undefined }, { id: undefined, createdAt: event.createdAt, level: event.level, eventType: "client.note", sourceModule: "system", title: "title", message: "message", entityType: "server", entityId: "1", payload: event.payload })
assert.equal(rows[1].entityId, undefined)
assertEventBatchBudget(rows)
})
test("event admission bounds bytes, JSON complexity and unsupported values before SQL", () => {
for (const input of [{ events: [] }, { events: Array(201).fill(event) }, { events: [{ ...event, id: "forged" }] }, { events: [event], receiptId: "forged" }]) assert.equal(eventsBatchSchema.safeParse(input).success, false)
assert.throws(() => assertEventBatchBudget({ message: "я".repeat(32769) }))
assert.throws(() => assertEventBatchBudget({ value: BigInt(1) }))
assert.throws(() => assertEventBatchBudget({ value: NaN }))
let nested: unknown = null; for (let depth = 0; depth < 14; depth++) nested = { nested }
assert.throws(() => assertEventBatchBudget(nested))
})
@@ -0,0 +1,17 @@
import { z } from "zod"
import { appendEventSchema } from "@mmapp/contracts/events"
export const eventsBatchSchema = z.object({ events: z.array(appendEventSchema.strict()).min(1).max(200) }).strict()
/** Bound opaque JSON before encryption, SQL, preview and hashing. */
export function assertEventBatchBudget(value: unknown) {
let nodes = 0
const visit = (item: unknown, depth: number) => {
if (++nodes > 20000 || depth > 12) throw new Error("Event JSON complexity limit")
if (item === null || typeof item === "string" || typeof item === "boolean") return
if (typeof item === "number" && Number.isFinite(item)) return
if (Array.isArray(item)) { for (const entry of item) visit(entry, depth + 1); return }
if (item && typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype) { for (const entry of Object.values(item)) if (entry !== undefined) visit(entry, depth + 1); return }
throw new Error("Event payload must be JSON")
}
visit(value, 0)
if (Buffer.byteLength(JSON.stringify(value), "utf8") > 65536) throw new Error("Event JSON byte limit")
}
+74
View File
@@ -0,0 +1,74 @@
import { z } from "zod"
import { evoBgpSettingsPatchSchema, normalizeEvoBgpBaseUrl, normalizeEvoBgpApiKey, type EvoBgpSavePayload } from "@mmapp/contracts/evobgp"
import { pool } from "../../db/index.js"
import { publicEvoBgpSettings, readEvoBgpSettings, writeEvoBgpSettings } from "../../services/evobgp-settings.js"
import { registerMcpService } from "./service-registry.js"
import { decryptSecret, fingerprint, McpError } from "./security.js"
import { probeEvoBgp } from "../../services/evobgp-probe.js"
import type { add } from "./actions.js"
const schema = z.object({
settings: z.object({ baseUrl: evoBgpSettingsPatchSchema.shape.baseUrl, enabled: z.boolean().optional() }).strict(),
apiKeyRef: z.string().uuid().nullable().optional(),
}).strict().refine(input => Object.values(input.settings).some(value => value !== undefined) || input.apiKeyRef !== undefined, "Укажите изменённые настройки или ссылку на ключ")
export function registerEvoBgpActions(register: typeof add) {
const id = "evobgp.settings.update"
const dependencies = async () => (await pool.query<{ id: number }>("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id))
register("evobgp.connection.check", "mm_evobgp_connection_check", "Проверка доступности EvoBGP без сохранения настроек", "settings", "mm:settings:admin", "read", "global", z.object({ baseUrl: evoBgpSettingsPatchSchema.shape.baseUrl, apiKeyRef: z.string().uuid().optional() }).strict(), {
read: async (input, context) => {
const ids = await dependencies()
if (ids.length > 1000 || ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Проверка EvoBGP требует полного каталога серверов")
const row = await readEvoBgpSettings(), root = normalizeEvoBgpBaseUrl(input.baseUrl === undefined ? row.baseUrl : String(input.baseUrl))
let key = root === normalizeEvoBgpBaseUrl(row.baseUrl) ? row.apiKey : ""
if (input.apiKeyRef) {
const { rows: [secret] } = await pool.query("SELECT purpose,server_id,ciphertext FROM mcp_secrets WHERE id=$1 AND created_by=$2", [input.apiKeyRef, context.connection.owner_id])
if (!root || !secret || secret.server_id !== null || secret.purpose !== `evobgp-api:${root}`) throw new McpError("SECRET_UNAVAILABLE", "Секрет не выдан владельцу для этого URL EvoBGP")
const parsed = evoBgpSettingsPatchSchema.safeParse({ apiKey: await decryptSecret(secret.ciphertext) })
if (!parsed.success || !parsed.data.apiKey) throw new McpError("SECRET_UNAVAILABLE", "Некорректный секрет EvoBGP")
key = parsed.data.apiKey
}
key = normalizeEvoBgpApiKey(key)
if (!root || !key) throw new McpError("SECRET_UNAVAILABLE", "Нужны URL и привязанный к нему API-ключ EvoBGP", 409)
await probeEvoBgp(root, key, context.signal)
if (fingerprint(await readEvoBgpSettings()) !== fingerprint(row)) throw new McpError("STATE_CONFLICT", "Настройки изменились во время проверки; повторите запрос", 409)
return { ok: true, checkedAt: new Date().toISOString(), configurationSaved: false, catalogVerified: false, routingVerified: false }
},
})
const current = async () => {
const row = await readEvoBgpSettings()
return { settings: publicEvoBgpSettings(row), configurationVersion: fingerprint(row), serverIds: await dependencies() }
}
registerMcpService(id, {
current,
execute: async input => { await writeEvoBgpSettings(input.patch as EvoBgpSavePayload); return { settings: publicEvoBgpSettings(await readEvoBgpSettings()) } },
verify: async input => {
const saved = await readEvoBgpSettings(), patch = evoBgpSettingsPatchSchema.parse(input.patch)
return Object.entries(patch).every(([key, value]) => fingerprint(saved[key as keyof typeof saved]) === fingerprint(key === "apiKey" ? value ?? "" : value))
},
})
register(id, "mm_evobgp_settings_update_plan", "План настроек EvoBGP с ключом, привязанным к URL", "settings", "mm:settings:admin", "write", "global", schema, {
prepare: async (input, context) => {
const before = await current()
if (before.serverIds.length > 1000 || before.serverIds.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Настройка EvoBGP требует полного каталога серверов")
const requested = schema.parse(input), row = await readEvoBgpSettings()
const root = requested.settings.baseUrl ?? row.baseUrl
const patch: EvoBgpSavePayload = { ...requested.settings }
if (root !== row.baseUrl && row.apiKey && requested.apiKeyRef === undefined) throw new McpError("SECRET_UNAVAILABLE", "Для нового URL нужен привязанный к нему секрет либо явное удаление прежнего ключа")
if (requested.apiKeyRef === null) patch.apiKey = null
else if (requested.apiKeyRef) {
const { rows: [secret] } = await pool.query("SELECT purpose,server_id,ciphertext FROM mcp_secrets WHERE id=$1 AND created_by=$2", [requested.apiKeyRef, context.connection.owner_id])
if (!root || !secret || secret.server_id !== null || secret.purpose !== `evobgp-api:${root}`) throw new McpError("SECRET_UNAVAILABLE", "Секрет не выдан владельцу для этого URL EvoBGP")
const parsed = evoBgpSettingsPatchSchema.safeParse({ apiKey: await decryptSecret(secret.ciphertext) })
if (!parsed.success || !parsed.data.apiKey) throw new McpError("SECRET_UNAVAILABLE", "Секрет EvoBGP должен содержать непустой однострочный ключ")
patch.apiKey = parsed.data.apiKey
}
return {
target: { scope: "global" }, before, snapshots: [], mutationCost: 2,
objects: [{ kind: "evobgp-settings", id: "1" }, ...before.serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId }))],
steps: [{ kind: "service", service: id, input: { patch } }], verificationScope: "configuration", requiresHuman: true,
warnings: ["Изменяется общая интеграция EvoBGP. Подтверждается сохранение настроек и ключа; доступность API и свежесть каталога требуют отдельной проверки.", "Ключ хранится в зашифрованном плане и не возвращается модели. Смена URL с прежним ключом требует нового секрета для назначения или явного удаления ключа."],
}
},
})
}
+28
View File
@@ -0,0 +1,28 @@
import { normalizeEvoBgpBaseUrl, normalizeEvoBgpApiKey } from "@mmapp/contracts/evobgp"
import { readEvoBgpSettings } from "../../services/evobgp-settings.js"
import { fetchBoundedEvoCatalog } from "../../services/evobgp-client.js"
import { parseEvoCatalogRaw } from "../../services/evobgp-catalog.js"
import { formatEvoBgpCatalog } from "../../services/evobgp-catalog-model.js"
import { distinctServersByFilterCommunityValue } from "../../services/evobgp-community-counts.js"
import { fingerprint, McpError } from "./security.js"
import type { ActionContext } from "./action-types.js"
/** Invoked only after the global read helper has verified the entire scope. */
export async function readEvoBgpCatalogPage(input: Record<string, unknown>, context: ActionContext) {
const settings = await readEvoBgpSettings()
if (!settings.enabled) throw new McpError("INTEGRATION_DISABLED", "Интеграция EvoBGP выключена", 409)
const root = normalizeEvoBgpBaseUrl(settings.baseUrl), key = normalizeEvoBgpApiKey(settings.apiKey)
if (!root || !key) throw new McpError("SECRET_UNAVAILABLE", "Не заданы URL и ключ EvoBGP", 409)
const raw = parseEvoCatalogRaw(await fetchBoundedEvoCatalog(root, key, undefined, { signal: context.signal }))
const counts = await distinctServersByFilterCommunityValue()
const result = formatEvoBgpCatalog(raw, counts), offset = Number(input.offset), limit = Number(input.limit)
// A misconfigured upstream can reflect a credential into a title. Never
// return that literal even when it is nested under an ordinary field name.
const withoutCredential = (value: unknown): unknown => typeof value === "string" ? value.includes(key) ? "[redacted]" : value : Array.isArray(value) ? value.map(withoutCredential) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([field, item]) => [field, withoutCredential(item)])) : value
const collections = { modules: result.modules, domains: result.domains, ipRanges: result.ipRanges, asns: result.asns, communities: result.communities }
const pages = Object.fromEntries(Object.entries(collections).map(([name, rows]) => [name, { total: rows.length, nextOffset: offset + limit < rows.length ? offset + limit : null }]))
return withoutCredential({ fetchedAt: result.fetchedAt, catalogVersion: fingerprint({ raw, counts: [...counts].sort(([a], [b]) => a.localeCompare(b)) }), offset, limit, pages,
...Object.fromEntries(Object.entries(collections).map(([name, rows]) => [name, rows.slice(offset, offset + limit)])),
incomplete: Object.values(collections).some(rows => rows.slice(offset, offset + limit).length < rows.length), serverCountsScope: "full-effective-catalog", source: "evobgp",
})
}
@@ -0,0 +1,125 @@
import { isIP } from "node:net"
import { z } from "zod"
import { pool } from "../../db/index.js"
import { applyFiltersToServer, cacheRulesetsForServer, toRouterRuleBody } from "../../routes/filters.js"
import { readCachedRecursiveRoutes } from "../../routes/recursive-routes.js"
import { canonicalFilterRules, getRevisionById, fingerprintPayload } from "../../services/config-revisions.js"
import { filterRulesWriteSchema, validatedFilterRules } from "../../services/filter-rules-write-model.js"
import { managedFilterNeedsWrite, readManagedFilterState } from "../../services/filter-rules-mutation.js"
import { managedComment, isOwnedRecursiveComment } from "../../managed-markers.js"
import { nativeBgpRows, type NativeBgpRow } from "../../services/bgp-export-model.js"
import { planBgpInApply } from "../../services/config-apply-plan.js"
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
import { routerClient } from "./resources.js"
import { registerMcpService } from "./service-registry.js"
import type { ChangeEvidence } from "./action-types.js"
import type { add } from "./actions.js"
const sid = z.number().int().positive().safe(), permissions = ["mm:filters:write", "mm:network:read", "mm:bgp:read"]
const unbound = z.boolean().default(false).describe("Требовать отсутствие BGP/OSPF/RIP/IS-IS instances/connections, select rules и других routing filters; безопасная начальная настройка без активного routing context")
const schema = z.object({ serverId: sid, rules: filterRulesWriteSchema, source: z.enum(["apply", "copy"]).default("apply"), requireUnbound: unbound }).strict()
const restoreSchema = z.object({ serverId: sid, revisionId: z.string().min(1).max(100), requireUnbound: unbound }).strict()
const proof = (serverId: number, domain: string, value: unknown) => privateSecretFingerprint(fingerprint(value), `filters-replace:${serverId}:${domain}`)
const stable = (row: NativeBgpRow) => Object.fromEntries(Object.entries(row).filter(([key]) => !["active", "inactive", "invalid", "running", "uptime", "expires-after", "last-started", "prefix-count", "immediate-gw", "gateway-status", "unreachable"].includes(key) && !key.startsWith("debug.")))
const paths = { gre: "/interface/gre", ipv4_address: "/ip/address", ipv6_address: "/ipv6/address", ipv4_route: "/ip/route?static=true", ipv6_route: "/ipv6/route?static=true", bgp_connection: "/routing/bgp/connection", bgp_template: "/routing/bgp/template", bgp_instance: "/routing/bgp/instance", ospf_instance: "/routing/ospf/instance", rip_instance: "/routing/rip/instance", isis_instance: "/routing/isis/instance", routing_filter_select: "/routing/filter/select-rule", routing_table: "/routing/table", vrf: "/ip/vrf" }
async function state(input: Record<string, unknown>) {
try {
const serverId = Number(input.serverId), rules = validatedFilterRules(input.rules), { server, client } = await routerClient(serverId)
const serverProof = await proof(serverId, "server", server)
if (input.expectedServerProof !== undefined && input.expectedServerProof !== serverProof) throw new McpError("STATE_CONFLICT", "Назначение или credentials фильтров изменились; новый источник не опрашивается", 409)
let revisionProof: string | null = null
if (input.revisionId !== undefined) {
const revision = await getRevisionById(String(input.revisionId), { serverId, section: "filters" })
if (!revision || revision.fingerprint !== fingerprintPayload(revision.payload) || fingerprint(canonicalFilterRules(validatedFilterRules(revision.payload))) !== fingerprint(canonicalFilterRules(rules))) throw new McpError("STATE_CONFLICT", "Исходная ревизия filters удалена, изменена или повреждена", 409)
revisionProof = await proof(serverId, "revision", revision)
}
const native = await readManagedFilterState(client)
if (planBgpInApply(native.rows, rules.length).conflictIds.length) throw new McpError("STATE_CONFLICT", "Неуправляемые Community-фильтры не могут быть автоматически заменены", 409)
const [cached, recursive, ...snapshots] = await Promise.all([cacheRulesetsForServer(serverId), readCachedRecursiveRoutes(serverId), ...Object.values(paths).map(async path => {
const rows = nativeBgpRows(await client.get(path))
if (rows.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Каталог зависимостей фильтров превышает 1000 строк", 409)
return rows
})])
if (cached.length > 1000 || recursive.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Метаданные фильтров или recursive превышают 1000 строк", 409)
const dependencies = Object.fromEntries(Object.keys(paths).map((key, index) => [key, snapshots[index]])) as Record<keyof typeof paths, NativeBgpRow[]>
const contexts = ["bgp_instance", "bgp_connection", "ospf_instance", "rip_instance", "isis_instance", "routing_filter_select"] as const
if (input.requireUnbound === true && (contexts.some(key => dependencies[key].length > 0) || native.rows.length !== native.managed.length)) throw new McpError("STATE_CONFLICT", "RequireUnbound: на сервере есть routing context, select rules или другие фильтры", 409)
const refs: ChangeEvidence["objects"] = [{ kind: "filters", id: "managed-bgp-in", serverId }, ...native.rows.map(row => ({ kind: "routing_filter", id: row[".id"], serverId }))]
for (const rule of rules) {
refs.push({ kind: "filter-community", id: rule.community, serverId })
if (rule.action === "blackhole" || !rule.gatewayTunnelId) continue
if (rule.gatewayTunnelId.startsWith("rec:")) {
const row = recursive.find(row => "rec:" + row.id === rule.gatewayTunnelId)
if (!row || row.dstAddress.split("/")[0] !== rule.gateway || isIP(rule.gateway) !== 4) throw new McpError("STATE_CONFLICT", "Recursive reference/gateway не совпадают на выбранном сервере", 409)
const matching = dependencies.ipv4_route.filter(route => route.static === "true" && route.dynamic === "false" && typeof route.comment === "string" && isOwnedRecursiveComment(route.comment) && route["dst-address"] === (row.dstAddress.includes("/") ? row.dstAddress : row.dstAddress + "/32") && route["routing-table"] === row.routingTable && route.gateway === row.gateway)
if (matching.length !== 1) throw new McpError("STATE_CONFLICT", "Native recursive dependency отсутствует или неоднозначна", 409)
refs.push({ kind: "recursive-route", id: row.id, serverId }, { kind: "ipv4_route", id: matching[0][".id"], serverId })
} else {
const matching = dependencies.gre.filter(row => row.name === rule.gatewayTunnelId)
if (matching.length !== 1) throw new McpError("STATE_CONFLICT", "GRE dependency отсутствует или неоднозначна", 409)
refs.push({ kind: "gre", id: matching[0][".id"], serverId })
}
}
for (const row of cached) if (/^\d{1,5}:\d{1,5}$/.test(row.community)) refs.push({ kind: "filter-community", id: row.community, serverId })
for (const kind of [...contexts, "bgp_template"] as const) for (const row of dependencies[kind]) refs.push({ kind, id: row[".id"], serverId })
const protections = (await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(serverId)])).rows
if (protections.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Проверка защит превышает 1000 объектов", 409)
for (const { object_ref: ref } of protections) refs.push({ kind: ref.kind, id: ref.id, serverId })
const objects = [...new Map(refs.map(ref => [fingerprint(ref), ref])).values()]
if (objects.length > 1000) throw new McpError("LIMIT_EXCEEDED", "Зависимости фильтров превышают 1000 объектов", 409)
const dependencyData = Object.fromEntries(Object.entries(dependencies).map(([key, rows]) => [key, rows.filter(row => !(key === "ipv6_address" && row.dynamic === "true" && typeof row.address === "string" && /^fe80:/i.test(row.address))).map(stable)]))
const dependencyComponents = Object.fromEntries(await Promise.all(Object.entries(dependencyData).map(async ([key, rows]) => [key, await proof(serverId, "component:" + key, rows)])))
const compiled = await toRouterRuleBody(serverId, rules, dependencies.gre)
const before = { serverId, serverProof, revisionProof, requireUnbound: input.requireUnbound === true, routingContexts: Object.fromEntries(contexts.map(key => [key, dependencies[key].length])), nativeDispatch: managedFilterNeedsWrite(native, compiled, managedComment(server.name || server.host)), cachedRuleCount: cached.length, managedNativeCount: native.managed.length, nativeFilterCount: native.rows.length,
previousRules: cached.map(rule => ({ community: /^\d{1,5}:\d{1,5}$/.test(rule.community) ? rule.community : null, action: rule.action === "blackhole" ? "blackhole" : rule.action === "route" ? "route" : "unknown", gateway: isIP(rule.gateway) ? rule.gateway : null, referenceKind: rule.gatewayTunnelId.startsWith("rec:") ? "recursive" : rule.gatewayTunnelId ? "interface" : "literal", hasDescription: !!rule.description, hasCommunityName: !!rule.communityName })),
desiredRules: canonicalFilterRules(rules), nativeProof: await proof(serverId, "native", native.proofRows), metadataProof: await proof(serverId, "metadata", canonicalFilterRules(cached)),
dependencyProof: await proof(serverId, "dependencies", { serverProof, dependencyData, recursive }), dependencyComponents, compiledProof: await proof(serverId, "compiled", compiled) }
return { server, client, rules, native, objects, before, compiled }
} catch (error) { if (error instanceof McpError) throw error; throw new McpError("STATE_UNAVAILABLE", "Не подтверждены исходные filters/recursive/сетевые зависимости; запись не выполняется", 409) }
}
export function registerFilterWriteActions(register: typeof add) {
registerMcpService("filters.managed.replace", {
current: async input => (await state(input)).before,
execute: async input => {
const initial = await state(input), serverId = initial.server.id
if (fingerprint(initial.before) !== input.beforeFingerprint) throw new McpError("STATE_CONFLICT", "Filters или зависимости изменились после подтверждения", 409)
const result = await applyFiltersToServer(initial.server, initial.rules, input.source === "copy" ? "copy" : input.source === "rollback" ? "rollback" : "apply", { beforeWrite: async (body, native) => {
const live = await state(input)
if (fingerprint(live.before) !== input.beforeFingerprint || await proof(serverId, "compiled", body) !== input.compiledProof || await proof(serverId, "native", native.proofRows) !== input.nativeProof || await proof(serverId, "native", (await readManagedFilterState(live.client)).proofRows) !== input.nativeProof) throw new McpError("STATE_CONFLICT", "Источник фильтров изменился перед первой записью", 409)
} }).catch(error => {
if (error instanceof McpError && error.code === "VERIFICATION_FAILED") console.warn("filters.native-stage.failed", { serverId, code: error.code, reason: error.message })
throw error
})
const after = await readManagedFilterState(initial.client)
return { ...result, nativeProof: await proof(serverId, "native", after.proofRows), dependencyProof: input.dependencyProof, dependencyComponents: initial.before.dependencyComponents, configurationVerified: true, forwardingVerified: false }
},
verify: async (input, receipt) => {
const result = receipt as { nativeProof: string; dependencyProof: string; dependencyComponents: Record<string, string>; configurationVerified: boolean }, after = await state(input)
const nativeMatches = after.before.nativeProof === result.nativeProof, dependenciesMatch = after.before.dependencyProof === result.dependencyProof, metadataMatches = after.before.metadataProof === await proof(after.server.id, "metadata", canonicalFilterRules(after.rules))
if (!result.configurationVerified || !nativeMatches || !dependenciesMatch || !metadataMatches) {
console.warn("filters.verification.mismatch", { serverId: after.server.id, nativeMatches, dependenciesMatch, metadataMatches, changedComponents: Object.keys(after.before.dependencyComponents).filter(key => after.before.dependencyComponents[key] !== result.dependencyComponents?.[key]) })
return false
}
const revision = (await pool.query("SELECT payload FROM config_revisions WHERE server_id=$1 AND section='filters' ORDER BY created_at DESC,id DESC LIMIT 1", [after.server.id])).rows[0]
const revisionMatches = !!revision && fingerprintPayload(revision.payload) === fingerprintPayload(canonicalFilterRules(after.rules))
if (!revisionMatches) console.warn("filters.verification.revision-mismatch", { serverId: after.server.id })
return revisionMatches
},
})
const prepare = async (input: Record<string, unknown>): Promise<ChangeEvidence> => {
const saved = await state(input)
const request = { ...input, rules: saved.rules, beforeFingerprint: fingerprint(saved.before), expectedServerProof: saved.before.serverProof, nativeProof: saved.before.nativeProof, dependencyProof: saved.before.dependencyProof, compiledProof: saved.before.compiledProof }
return { target: { scope: "servers", serverIds: [saved.server.id] }, objects: saved.objects, snapshots: [], before: saved.before, mutationCost: (saved.before.nativeDispatch ? 1 : 0) + 1, requiresHuman: true, verificationScope: "configuration",
steps: [{ kind: "service", service: "filters.managed.replace", input: request }], warnings: ["Полностью заменяется один управляемый bgp-in набор. Пустой список удаляет этот набор; чужие фильтры и их порядок сохраняются.",
"Влияние распространяется на все routing clients, использующие bgp-in на этом сервере. BGP/OSPF/RIP/IS-IS и select contexts запечатаны; отдельная таблица/семейство не выбирается.",
"Обычный gateway без reference задаётся как literal IP. Наличие GRE/recursive проверяется, их доступность и forwarding этим планом не подтверждаются; disabled dependencies не включаются.",
"Private HMAC proofs скрывают ключи, native script и исходные описания. После unknown/partial нужен новый анализ, автоматического повтора и универсального inverse нет."] }
}
register("filters.rules.replace", "mm_filter_rules_replace_plan", "Человеческий план полной замены управляемых Community-фильтров bgp-in", "filters", permissions, "write", "servers", schema, { prepare })
register("filters.revision.restore", "mm_filter_revision_restore_plan", "Человеческий план восстановления ревизии управляемых Community-фильтров", "filters", permissions, "write", "servers", restoreSchema, { prepare: async input => {
const revision = await getRevisionById(String(input.revisionId), { serverId: Number(input.serverId), section: "filters" })
if (!revision || revision.fingerprint !== fingerprintPayload(revision.payload)) throw new McpError("STATE_CONFLICT", "Ревизия выбранного сервера/filters не найдена или повреждена", 409)
return prepare({ serverId: input.serverId, rules: validatedFilterRules(revision.payload), source: "rollback", revisionId: input.revisionId, requireUnbound: input.requireUnbound })
} })
}
+132
View File
@@ -0,0 +1,132 @@
import { z } from "zod"
import { pool } from "../../db/index.js"
import { firewallMoveSchema, readFirewallMoveState, movedFirewallRows, moveFirewallRule } from "../../services/firewall-rule-move.js"
import { routerClient, readRosSnapshot } from "./resources.js"
import { fingerprint, McpError } from "./security.js"
import { registerMcpService } from "./service-registry.js"
import type { add } from "./actions.js"
import type { ChangeEvidence } from "./action-types.js"
import { firewallRuleMutationSchema, readFirewallRuleState, assertFirewallRuleTarget, writeFirewallRule } from "../../services/firewall-rule-write.js"
import { firewallAddressMutationSchema, readFirewallAddressState, assertFirewallAddressTarget, writeFirewallAddress, expiryMatches, type AddressExpiry } from "../../services/firewall-address-write.js"
import { readFirewallRestoreRevision, readFirewallRestoreState, firewallRestoreProof, firewallRestoreOps, firewallRestoreCommandCount, restoreFirewallRevision } from "../../services/firewall-revision-restore.js"
export function registerFirewallActions(register: typeof add) {
const restoreId = "firewall.revision.restore"
const restoreCurrent = async (input: Record<string, unknown>) => {
const sid = Number(input.serverId), revision = await readFirewallRestoreRevision(sid, String(input.revisionId))
return { revisionFingerprint: revision.revisionFingerprint, state: firewallRestoreProof(await readFirewallRestoreState((await routerClient(sid)).server)) }
}
registerMcpService(restoreId, { current: restoreCurrent,
execute: async input => {
const { catalog: _catalog, ...receipt } = await restoreFirewallRevision((await routerClient(Number(input.serverId))).server, String(input.revisionId), String(input.beforeFingerprint), String(input.revisionFingerprint))
return receipt
},
verify: async (input, result) => {
const receipt = result as { verified?: boolean; afterFingerprint?: string } | null
return receipt?.verified === true && fingerprint(firewallRestoreProof(await readFirewallRestoreState((await routerClient(Number(input.serverId))).server))) === receipt.afterFingerprint
},
})
register(restoreId, "mm_firewall_revision_restore_plan", "План восстановления Firewall из ревизии", "network", "mm:network:write", "write", "servers", z.object({ serverId: z.number().int().positive().safe(), revisionId: z.string().uuid() }).strict(), {
prepare: async input => {
const sid = Number(input.serverId), revision = await readFirewallRestoreRevision(sid, String(input.revisionId)), state = await readFirewallRestoreState((await routerClient(sid)).server)
const ops = firewallRestoreOps(revision.desired, state), objects: ChangeEvidence["objects"] = [{ kind: "server", id: String(sid), serverId: sid }]
for (const chunk of state.chunks) for (const row of chunk.rows) objects.push({ kind: `${chunk.family === "ip6" ? "ipv6" : "ip"}_${chunk.table === "address-list" ? "address_list" : chunk.table}`, id: row[".id"], serverId: sid })
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(sid)])
if (protections.rows.length > 1000) throw new McpError("STATE_UNAVAILABLE", "Превышен предел проверки защит", 503)
for (const { object_ref: ref } of protections.rows) objects.push({ kind: ref.kind, id: ref.id, serverId: sid })
const before = { revisionFingerprint: revision.revisionFingerprint, state: firewallRestoreProof(state) }
return { target: { scope: "servers", serverIds: [sid] }, before, snapshots: [], objects, mutationCost: firewallRestoreCommandCount(ops) + 1, requiresHuman: true,
steps: [{ kind: "service", service: restoreId, input: { ...input, desired: revision.desired, beforeFingerprint: fingerprint(before.state), revisionFingerprint: revision.revisionFingerprint } }],
warnings: ["Заменяется весь постоянный Firewall IPv4/IPv6: filter/nat/mangle/raw и address-list. Меняются native ID; порядок и число одинаковых правил берутся из ревизии. Пустая ревизия удаляет весь статический набор.", "Новые записи сначала создаются отключёнными и проверяются; после успешной подготовки старые статические записи удаляются, нужные новые включаются. Это не атомарная транзакция: переключение может изменить управление и существующие соединения.", "Динамические записи не удаляются и сохраняют взаимный порядок; после замены они будут перед восстановленным статическим набором. Их исчезновение/появление во время операции останавливает дальнейшие стадии.", "Старые ревизии без native-полей восстанавливают только сохранённую форму, а не отсутствующие matcher-поля исходного устройства. Новые ревизии сохраняют native matcher-поля. RAM/timeout записи не восстанавливаются.", "Каждая стадия проверяется до следующей команды. Потерянный ответ не повторяется; обратного автоматического restore нет. После partial/unknown необходима сверка. Проверка пакетов/связности не выполняется."] }
},
})
for (const [index, operation] of ["create", "update", "toggle", "delete"].entries()) {
const service = `firewall.addressLists.${operation}`
const address = (input: Record<string, unknown>) => firewallAddressMutationSchema.options[index].parse(input.address)
const state = async (input: Record<string, unknown>) => readFirewallAddressState((await routerClient(Number(input.serverId))).server, address(input))
registerMcpService(service, { current: async input => {
const next = await state(input), mutation = address(input)
if (input.beforeFingerprint && mutation.operation !== "create" && !expiryMatches(input.expectedExpiry as AddressExpiry | undefined, next.timers[mutation.rosId])) throw new McpError("STATE_CONFLICT", "Срок address-list изменился после подтверждения", 409)
return next.stable
},
execute: async input => writeFirewallAddress((await routerClient(Number(input.serverId))).server, address(input), String(input.beforeFingerprint), input.expectedExpiry as AddressExpiry | undefined),
verify: async (input, result) => {
const receipt = result as { verified?: boolean; afterFingerprint?: string; affectedId?: string; afterExpiry?: AddressExpiry } | null
if (receipt?.verified !== true) return false
const next = await state(input)
return fingerprint(next.stable) === receipt.afterFingerprint && expiryMatches(receipt.afterExpiry, receipt.affectedId ? next.timers[receipt.affectedId] : undefined)
},
})
register(service, `mm_firewall_address_list_${operation}_plan`, `План ${["создания", "изменения", "переключения активности", "удаления"][index]} записи address-list`, "network", "mm:network:write", "write", "servers", z.object({ serverId: z.number().int().positive().safe(), address: firewallAddressMutationSchema.options[index] }).strict(), {
prepare: async input => {
const mutation = address(input), sid = Number(input.serverId), before = await state(input)
assertFirewallAddressTarget(mutation, before)
const resource = `${mutation.family === "ip6" ? "ipv6" : "ip"}_address_list`
const objects: ChangeEvidence["objects"] = before.stable.rows.map(row => ({ kind: resource, id: row[".id"], serverId: sid }))
objects.push({ kind: "server", id: String(sid), serverId: sid })
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(sid)])
if (protections.rows.length > 1000) throw new McpError("STATE_UNAVAILABLE", "Превышен предел проверки защит", 503)
for (const { object_ref: ref } of protections.rows) objects.push({ kind: ref.kind, id: ref.id, serverId: sid })
const expiry = mutation.operation === "create" ? undefined : before.timers[mutation.rosId]
if (expiry && expiry.earliest < Date.now() + 5000) throw new McpError("STATE_CONFLICT", "Address-list истекает слишком скоро для подтверждения", 409)
return { target: { scope: "servers", serverIds: [sid] }, before: before.stable, snapshots: [], objects, mutationCost: 2, requiresHuman: true,
...(expiry ? { validUntil: new Date(expiry.earliest - 2000).toISOString() } : {}),
steps: [{ kind: "service", service, input: { ...input, beforeFingerprint: fingerprint(before.stable), ...(expiry ? { expectedExpiry: expiry } : {}) } }],
warnings: ["Address-list может менять поведение всех использующих его правил; проверяются все зарегистрированные защиты сервера. Требуется человек.", "Timeout — оставшееся время: проверяется окно срока с учётом секунды точности native-таймера и длительности запроса. Изменение срока вне этого окна блокирует старый план; естественное уменьшение не считается дрейфом. Временная запись хранится в RAM и может исчезнуть.", "Форма сохраняет непустые переданные поля. Поддержка изменения dynamic-записи зависит от устройства; отказ не повторяется. Неоднозначный DNS-эффект или неподтверждённый read-back требует сверки, а не повторной команды. Packet matching и DNS forwarding не проверяются. Статус ревизии возвращается отдельно."] }
},
})
}
for (const [index, operation] of ["create", "update", "toggle", "delete"].entries()) {
const service = `firewall.rules.${operation}`
const rule = (input: Record<string, unknown>) => firewallRuleMutationSchema.options[index].parse(input.rule)
const current = async (input: Record<string, unknown>) => readFirewallRuleState((await routerClient(Number(input.serverId))).server, rule(input))
registerMcpService(service, { current,
execute: async input => writeFirewallRule((await routerClient(Number(input.serverId))).server, rule(input), String(input.beforeFingerprint)),
verify: async (input, result) => {
const receipt = result as { verified?: boolean; afterFingerprint?: string } | null
return receipt?.verified === true && fingerprint(await current(input)) === receipt.afterFingerprint
},
})
register(service, `mm_firewall_rule_form_${operation}_plan`, `План ${["создания", "изменения", "переключения активности", "удаления"][index]} правила Firewall через сервис приложения`, "network", "mm:network:write", "write", "servers", z.object({ serverId: z.number().int().positive().safe(), rule: firewallRuleMutationSchema.options[index] }).strict(), {
prepare: async input => {
const mutation = rule(input), sid = Number(input.serverId), before = await current(input)
assertFirewallRuleTarget(mutation, before)
const resource = `${mutation.family === "ip6" ? "ipv6" : "ip"}_${mutation.table}`
const objects: ChangeEvidence["objects"] = before.rows.map(row => ({ kind: resource, id: row[".id"], serverId: sid }))
objects.push({ kind: "server", id: String(sid), serverId: sid })
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(sid)])
if (protections.rows.length > 1000) throw new McpError("STATE_UNAVAILABLE", "Превышен предел проверки защит", 503)
for (const { object_ref: ref } of protections.rows) objects.push({ kind: ref.kind, id: ref.id, serverId: sid })
return { target: { scope: "servers", serverIds: [sid] }, before, snapshots: [await readRosSnapshot(sid, resource)], objects, mutationCost: 2, requiresHuman: true,
steps: [{ kind: "service", service, input: { ...input, beforeFingerprint: fingerprint(before) } }],
warnings: ["Firewall может влиять на управление, NAT и существующие соединения; требуются человеческое подтверждение и проверка защищённых зависимостей сервера.", "Порядок и все непрозрачные matcher-поля сохраняются; байтовые/пакетные счётчики не считаются дрейфом. Проверяется конфигурация, передача пакетов не проверяется.", "Пустые поля формы не очищают существующие matcher-поля, как и в REST. При потере ответа команда не повторяется; восстановление готовится отдельным планом. Статус ревизии возвращается отдельно."] }
},
})
}
const id = "firewall.rules.move"
const current = async (input: Record<string, unknown>) => readFirewallMoveState((await routerClient(Number(input.serverId))).server, firewallMoveSchema.parse(input.move))
registerMcpService(id, {
current,
execute: async input => moveFirewallRule((await routerClient(Number(input.serverId))).server, firewallMoveSchema.parse(input.move), String(input.beforeFingerprint)),
verify: async (input, result) => {
if (!(result as { verified?: boolean } | null)?.verified) return false
return fingerprint(await current(input)) === input.afterFingerprint
},
})
register(id, "mm_firewall_rule_move_plan", "План точного перемещения правила Firewall", "network", "mm:network:write", "write", "servers", z.object({ serverId: z.number().int().positive().safe(), move: firewallMoveSchema }).strict(), {
prepare: async input => {
const before = await current(input), move = firewallMoveSchema.parse(input.move), sid = Number(input.serverId)
const after = movedFirewallRows(before.rows, move)
if (fingerprint(before.rows) === fingerprint(after)) throw new McpError("STATE_CONFLICT", "Правило уже находится в запрошенной позиции", 409)
const resource = `${move.family === "ip6" ? "ipv6" : "ip"}_${move.table}`
const objects: ChangeEvidence["objects"] = before.rows.map(row => ({ kind: resource, id: row[".id"], serverId: sid }))
objects.push({ kind: "server", id: String(sid), serverId: sid })
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(sid)])
if (protections.rows.length > 1000) throw new McpError("STATE_UNAVAILABLE", "Превышен предел проверки защит", 503)
for (const { object_ref: ref } of protections.rows) objects.push({ kind: ref.kind, id: ref.id, serverId: sid })
return { target: { scope: "servers", serverIds: [sid] }, before, snapshots: [await readRosSnapshot(sid, resource)], objects, mutationCost: 2, requiresHuman: true,
steps: [{ kind: "service", service: id, input: { ...input, beforeFingerprint: fingerprint(before), afterFingerprint: fingerprint({ ...before, rows: after }) } }],
warnings: ["Меняется порядок всей выбранной таблицы Firewall. Полная конфигурация и порядок проверяются перед командой и после неё.", "Перемещаются только подтверждённо статические правила. Изменение может затронуть управление и существующие соединения; проверка пакетов/связности отдельно не выполняется.", "Ревизия сохраняется только при полном подтверждённом снимке; отсутствие ревизии видно в результате шага. При потере ответа команда не повторяется; автоматический inverse не создаётся."] }
},
})
}
@@ -0,0 +1,61 @@
import { assertBoundedFlowSource as assertTrafficHistorySource } from "./flow-source-scope.js"
import { withCurrentFlowReadQuality } from "./flow-read-quality.js"
import { z } from "zod"
import { flowAnalyticsDtoSchema } from "@mmapp/contracts/traffic-flow"
import { buildFlowAnalytics } from "../../services/traffic-flow-analytics.js"
import { readFlowSourceLimits } from "../../services/traffic-flow-source-limits.js"
import { getBoundedFlowServerRowById as getServerRowById } from "../../services/traffic-flow-server-source.js"
import { assertTrafficHistoryAccess } from "./traffic-history-actions.js"
import { McpError } from "./security.js"
import type { add } from "./actions.js"
export function registerFlowAnalyticsActions(register: typeof add) {
register("traffic.flow.analytics.read", "mm_netflow_analytics_report_get", "NetFlow: нагрузка, приложения и разговоры выбранного сервера", "traffic", "mm:traffic:read", "read", "servers", z.object({
serverId: z.number().int().positive().safe(), minutes: z.number().int().min(1).max(1440).default(15),
userId: z.string().min(1).max(100).optional(),
interface: z.string().trim().min(1).max(200).refine(value => !/[\u0000-\u001f\u007f]/.test(value) && value !== "__all__").optional(),
dedup: z.boolean().default(true), excludeMesh: z.boolean().default(true), excludeOverlay: z.boolean().default(true),
offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(100).default(50),
}).strict(), {
read: async (input, context) => {
await assertTrafficHistoryAccess(context)
const server = await getServerRowById(Number(input.serverId))
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const authorize = () => assertTrafficHistorySource(context, server)
const startedAt = new Date().toISOString()
try {
const limits = await readFlowSourceLimits()
await authorize()
const result = flowAnalyticsDtoSchema.parse(await buildFlowAnalytics({ serverId: server.id, minutes: Number(input.minutes), userId: input.userId as string | undefined,
iface: input.interface as string | undefined, dedup: input.dedup === true, excludeMesh: input.excludeMesh === true, excludeOverlay: input.excludeOverlay === true },
{ serverIds: context.connection.policy.scope.serverIds, maxRows: 20000, authorize }))
const offset = Number(input.offset), limit = Number(input.limit), pages: Record<string, { total: number; nextOffset: number | null }> = {}
const analytics: Record<string, unknown> = { ...result }
for (const [key, value] of Object.entries(result)) if (Array.isArray(value) && !["rxSeries", "txSeries"].includes(key)) {
analytics[key] = value.slice(offset, offset + limit)
pages[key] = { total: value.length, nextOffset: offset + limit < value.length ? offset + limit : null }
}
// Ring rates are server context, including when the retained rows are filtered by user/interface.
const { bpsNow: _bpsNow, bpsWire: _bpsWire, bpsOverlay: _bpsOverlay, bytesWire: _bytesWire, live: _live, ...retained } = analytics
await authorize()
if ((await readFlowSourceLimits()).topN !== limits.topN) throw new McpError("STATE_CONFLICT", "Предел источника NetFlow изменился во время чтения", 409)
await authorize()
const output = { serverId: server.id, minutes: Number(input.minutes), interface: input.interface ?? null, userId: input.userId ?? null,
analytics: retained, pages, contextRates: { bpsNowEstimate: _bpsNow, bpsWireEstimate: _bpsWire, bpsOverlayEstimate: _bpsOverlay, bytesWireEstimate: _bytesWire,
listenerBound: _live, userAttributionVerified: false, semantics: "REST estimates: current server/interface ring with retained-window fallback; wire projection uses current interface snapshots" },
readStartedAt: startedAt, observedAt: new Date().toISOString(), live: false,
source: "retained flow_buckets plus pending; same REST analytics engine with scoped topology",
rowLimit: 20000, topRowsPerBucketLimit: Math.max(20, limits.topN), engineTopListLimit: Math.min(50, Math.max(10, limits.topN)),
incomplete: true, coverageVerified: false, rowSumIsNetworkTotal: false, forwardingVerified: false,
inferredPathsVerified: false, historicalOwnershipVerified: false, attribution: "current named whole-interface bindings; users with hidden bindings excluded",
backgroundEnrichmentStarted: false, collectorStarted: false }
if (Buffer.byteLength(JSON.stringify(output)) > 1048576) throw new McpError("STATE_UNAVAILABLE", "Ответ аналитики превышает 1 МиБ; уменьшите страницу", 409)
return withCurrentFlowReadQuality(output,context)
} catch (error) {
await assertTrafficHistoryAccess(context)
if (error instanceof McpError) throw error
throw new McpError("STATE_UNAVAILABLE", "Аналитика недоступна, изменилась во время чтения или превышает предел; уменьшите окно", 409)
}
},
})
}
@@ -0,0 +1,82 @@
import { assertBoundedFlowSource as assertTrafficHistorySource } from "./flow-source-scope.js"
import { withCurrentFlowReadQuality } from "./flow-read-quality.js"
import { z } from "zod"
import { readFlowExporterInputs, readFlowClientInputs, observeFlowExporter, observeFlowClient } from "../../services/traffic-flow-catalog-read.js"
import { getFlowMonthly } from "../../services/traffic-flow-analytics.js"
import { readFlowCatalogWindow } from "../../services/traffic-flow-catalog-read.js"
import { projectFlowTalkers } from "../../services/traffic-flow-talkers-model.js"
import { resolveIfaceName } from "../../services/traffic-flow-ifindex.js"
import { assertTrafficHistoryAccess } from "./traffic-history-actions.js"
import { getTrafficFlowSettingsRow } from "../../services/traffic-flow-settings.js"
import { McpError } from "./security.js"
import { catalogPaging } from "./catalog-scope.js"
import { flowCatalogDevices as catalogDevices } from "./flow-source-scope.js"
import type { add } from "./actions.js"
const sourceSemantics = { coverageVerified: false, rowSumIsNetworkTotal: false, deduplicatedAcrossInterfaces: false, forwardingVerified: false }
export function registerFlowCatalogActions(register: typeof add) {
const schema = catalogPaging.safeExtend({ minutes: z.number().int().min(1).max(1440).default(15) })
register("traffic.flow.talkers.read", "mm_netflow_talkers_get", "NetFlow: основные разговоры разрешённых серверов", "traffic", "mm:traffic:read", "read", "servers", schema, {
read: async (input, context) => {
await assertTrafficHistoryAccess(context)
const { selected, ...page } = await catalogDevices(input, context, { includeDisabled: true })
try {
const window = await readFlowCatalogWindow(Number(input.minutes), selected.map(row => row.id))
const topN = window.topConversationsLimit ?? 20
const projection = projectFlowTalkers(window.rows, Number(input.minutes), topN, new Map(selected.map(row => [row.id, row.name])), resolveIfaceName)
const offset = Number(input.offset), limit = Number(input.limit), items = projection.talkers.slice(offset, offset + limit)
for (const server of selected) await assertTrafficHistorySource(context, server)
if (selected.length && (await getTrafficFlowSettingsRow()).topN !== topN) throw new McpError("STATE_CONFLICT", "Предел источника NetFlow изменился во время чтения", 409)
await assertTrafficHistoryAccess(context)
return withCurrentFlowReadQuality({ ...page, items, total: projection.talkers.length, totalConversations: projection.totalConversations,
nextOffset: offset + items.length < projection.talkers.length ? offset + items.length : null,
retainedRowsBytes: projection.retainedRowsBytes, observedExporters: projection.observedExporters,
uniqueSrc: projection.uniqueSrc, uniqueDst: projection.uniqueDst, topProto: projection.topProto,
topConversationsLimit: topN, topRowsPerBucketLimit: window.topRowsPerBucketLimit,
incomplete: true, from: window.since, to: window.until,
source: "flow_buckets plus pending; scope before SQL and retained conversation aggregation",
bpsMeaning: "retained conversation bytes averaged over the entire requested window",
...sourceSemantics },context)
} catch (error) {
await assertTrafficHistoryAccess(context)
if (error instanceof McpError) throw error
throw new McpError("STATE_UNAVAILABLE", "Разговоры NetFlow недоступны или превышен предел источника 50000 строк; сократите область или окно", 409)
}
},
})
register("traffic.flow.exporters.read", "mm_netflow_exporters_get", "NetFlow: экспортёры выбранной области", "traffic", "mm:traffic:read", "read", "servers", schema, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context, { includeDisabled: true })
const data = await readFlowExporterInputs(Number(input.minutes), selected.map(row => row.id))
const present = new Set([...data.window.rows.map(row => row.serverId), ...data.configuredIds])
const all = data.catalog.list.filter(row => present.has(row.id)).sort((a, b) => a.id - b.id)
const offset = Number(input.offset), limit = Number(input.limit)
return withCurrentFlowReadQuality({ ...page, exporters: all.slice(offset, offset + limit).map(row => observeFlowExporter(data, row.id, Number(input.minutes))), total: all.length,
nextOffset: offset + limit < all.length ? offset + limit : null, incomplete: page.incomplete || all.length > limit || offset > 0 || data.configurationMembershipVerified === false,
configurationMembershipVerified: data.configurationMembershipVerified, configurationEvidence: "stored-peer-records; device exporter settings and keys not verified", source: "flow_buckets plus pending; retained top rows per server/bucket",
failures: data.configurationMembershipVerified === false ? [{ source: "selected-peer-membership", code: "UNVERIFIED_SCHEMA" }] : [],
topRowsPerBucketLimit: data.window.topRowsPerBucketLimit, from: data.window.since, to: data.window.until, ...sourceSemantics },context)
},
})
register("traffic.flow.clients.read", "mm_netflow_clients_get", "NetFlow: клиенты по доступным привязкам", "traffic", "mm:traffic:read", "read", "servers", schema.safeExtend({ limit: z.number().int().min(1).max(50).default(20) }), {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context, { includeDisabled: true })
const offset = Number(input.offset), limit = Number(input.limit)
const data = await readFlowClientInputs(Number(input.minutes), { serverIds: selected.map(row => row.id), allowedServerIds: context.connection.policy.scope.serverIds, offset, limit })
const clients = data.users.sort((a, b) => a.id.localeCompare(b.id)).map(user => observeFlowClient(user, data.window.rows, Number(input.minutes)))
return withCurrentFlowReadQuality({ ...page, clients, total: data.total, nextOffset: offset + clients.length < data.total ? offset + clients.length : null,
incomplete: page.incomplete || clients.length < data.total || clients.some(row => !row.completeSourceSet || !row.completeAttribution),
source: "retained ingress rows and current named whole-interface bindings", topRowsPerBucketLimit: data.window.topRowsPerBucketLimit,
from: data.window.since, to: data.window.until, ...sourceSemantics },context)
},
})
register("traffic.flow.monthly.read", "mm_netflow_monthly_report_get", "NetFlow: месячный отчёт выбранной области", "traffic", "mm:traffic:read", "read", "servers", catalogPaging.safeExtend({ month: z.string().regex(/^(?!0000)\d{4}-(0[1-9]|1[0-2])$/) }), {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context, { includeDisabled: true })
const statistics = await getFlowMonthly(String(input.month), selected.map(row => row.id), { maxKeys: 10000 }), offset = Number(input.offset), limit = Number(input.limit)
const collections = Object.fromEntries((["countries", "services", "asns"] as const).map(key => [key, { items: statistics[key].slice(offset, offset + limit), total: statistics[key].length, nextOffset: offset + limit < statistics[key].length ? offset + limit : null }]))
return withCurrentFlowReadQuality({ ...page, month: statistics.month, bytes: statistics.bytes, collections, incomplete: page.incomplete || Object.values(collections).some(value => value.items.length < value.total),
source: "flow_daily_dims; scope before SQL SUM/GROUP BY", bpsMeaning: "stored bytes averaged over the entire calendar month", coverageVerified: false, deduplicationVerified: false, wireRateVerified: false, forwardingVerified: false },context)
},
})
}
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { planFlowEnvelope } from "./flow-envelope-allowance.js"
import { boundedToolError, boundedToolResult } from "./result-budget.js"
import { deliverRegisteredToolResult } from "./result-delivery.js"
const info = { name: "MikrotikManager", version: "1.0.0" }
const read = { id: "traffic.flow.fixture", effect: "read" as const, hasRead: true, hasPrepare: false }
test("same2MiB/8MiB caps, exact id/framing, existing1024 reserve and B1 ceiling", () => {
for (const cap of [2, 8].map(value => value * 1048576)) for (const era of ["legacy", "modern"] as const) {
const allowance = planFlowEnvelope("я\\\"".repeat(100), era, cap, info)
assert(allowance.readByteLimit <= 1048576)
assert(allowance.readByteLimit + allowance.reservedEnvelopeBytes <= cap)
assert.equal(allowance.reservedEnvelopeBytes, allowance.terminalEnvelopeBytes + 1024)
}
assert(planFlowEnvelope("a".repeat(1048576 - 2), "modern", 2 * 1048576, info).readByteLimit < 1048576)
})
test("read narrowing never produces empty healthy quality or fake metadata", () => {
const decision = boundedToolResult({ quality: "unknown", metadata: { received: 2, dropped: 1 }, rows: "x".repeat(5000) }, "read", undefined, 8192)
assert.equal(decision.kind, "result")
if (decision.kind === "result") { assert.equal(decision.result.isError, true); assert.equal(decision.result.structuredContent, undefined); assert(!JSON.stringify(decision.result).includes('"quality"')) }
})
test("read allowance is validated before effects and cannot change applied outcome budget", async () => {
let invoked = 0
for (const contract of [read, { ...read, id: "changes.apply", effect: "write" as const }]) {
const result = await deliverRegisteredToolResult(contract, async () => { invoked++; return {} }, () => boundedToolError("INTERNAL_ERROR", "ошибка"), undefined, 100)
assert.equal(result.isError, true)
}
assert.equal(invoked, 0)
})
test("bounded invocation error is retained too; fitting quality and metadata stay intact", async () => {
const charged: number[] = []
const error = await deliverRegisteredToolResult(read, async () => { throw Error("private") }, () => boundedToolError("STATE_UNAVAILABLE", "Источник временно недоступен"), bytes => charged.push(bytes), 8192)
assert.equal(error.isError, true); assert.equal(charged[0], Buffer.byteLength(JSON.stringify(error)))
const dto = { quality: "unknown", metadata: { received: 2, dropped: 1 } }
const fit = await deliverRegisteredToolResult(read, async () => dto, () => { throw Error("unexpected") }, undefined, 8192)
assert.deepEqual(fit.structuredContent, { result: dto })
})
@@ -0,0 +1,39 @@
import { MCP_CALL_RESULT_BYTES } from "./result-budget.js"
import { MCP_REQUEST_BODY_BYTES } from "./wire-admission.js"
export const FLOW_PROTOCOL_RESERVE_BYTES = 1024 // existing per-result reserve, retained
type ServerInfo = { name: string; version: string }
/** Shared current tools/call stamp arithmetic, NOT a stream/heap bound.
* Discovery/cache fields are measured in the actual encoded SDK message. */
export function measureMcpTerminalEnvelope(idBytes: number, era: "legacy" | "modern", serverInfo: ServerInfo) {
if (!Number.isSafeInteger(idBytes) || idBytes < 1 || !["legacy", "modern"].includes(era)) throw Error("Unproved wire context")
const modernStamp = era === "modern" ? Buffer.byteLength(JSON.stringify({ resultType: "complete", _meta: { "io.modelcontextprotocol/serverInfo": serverInfo } })) - 1 : 0
const jsonRpcEnvelopeBytes = idBytes + 33 + modernStamp
const terminalEnvelopeBytes = jsonRpcEnvelopeBytes + (era === "legacy" ? 23 : 0)
if (!Number.isSafeInteger(modernStamp) || modernStamp < 0 || !Number.isSafeInteger(jsonRpcEnvelopeBytes) || !Number.isSafeInteger(terminalEnvelopeBytes)) throw Error("Unproved wire arithmetic")
return Object.freeze({ idBytes, jsonRpcEnvelopeBytes, terminalEnvelopeBytes, modernStamp })
}
/** HTTP admission domain remains 1MiB. The pure codec measurement above is
* not permission to widen HTTP/Flow limits for another transport. */
export function planMcpTerminalEnvelope(idBytes: number, era: "legacy" | "modern", serverInfo: ServerInfo) {
if (!Number.isSafeInteger(idBytes) || idBytes < 1 || idBytes > MCP_REQUEST_BODY_BYTES || !["legacy", "modern"].includes(era)) throw Error("Unproved wire context")
return measureMcpTerminalEnvelope(idBytes, era, serverInfo)
}
/**
* Narrow current tools/call contract: B1 structuredContent is object-shaped;
* modern default-auto has no related notifications on these NetFlow reads.
* Own top-level _meta on an error uses less stamp space than the no-meta case.
* Bounds the terminal message, NOT keepalive/HTTP headers/global heap.
* Existing NetFlow allowance/caps unchanged; delegates only the arithmetic.
*/
export function planFlowEnvelope(id: unknown, era: "legacy" | "modern", outputCap: number, serverInfo: ServerInfo) {
if (typeof id !== "string" && typeof id !== "number") throw Error("Unproved request id")
if (typeof id === "number" && !Number.isFinite(id) || !["legacy", "modern"].includes(era)) throw Error("Unproved wire context")
const idBytes = Buffer.byteLength(JSON.stringify(id))
if (!Number.isSafeInteger(outputCap) || outputCap <= 0 || idBytes > MCP_REQUEST_BODY_BYTES) throw Error("Unproved output capacity")
const { terminalEnvelopeBytes } = planMcpTerminalEnvelope(idBytes, era, serverInfo)
const reservedEnvelopeBytes = terminalEnvelopeBytes + FLOW_PROTOCOL_RESERVE_BYTES
const readByteLimit = Math.min(MCP_CALL_RESULT_BYTES, outputCap - reservedEnvelopeBytes)
if (readByteLimit < 8192) throw Error("No bounded read-result allowance")
return Object.freeze({ idBytes, terminalEnvelopeBytes, reservedEnvelopeBytes, readByteLimit })
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { setTimeout as delay } from "node:timers/promises"
import type { FastifyInstance } from "fastify"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { pool } from "../../db/index.js"
import { ensurePartitionFor } from "../../db/partitions.js"
import { env } from "../../config.js"
import type { AuthUser } from "../../lib/permissions.js"
import { rememberServerIfaces } from "../../services/traffic-flow-ifindex.js"
import { seedFlowCatalogForTests } from "../../services/traffic-flow-classify.js"
import { seedRipeCacheForTests } from "../../services/traffic-flow-ripe.js"
import { getServerCatalog, loadFlowTopology, invalidateFlowCatalogCache } from "../../services/traffic-flow-topology.js"
import { getFlowListenerState, getFlowWorkerHealth, ingestParsedFlowsForServerForTests, peekPendingFlows } from "../../services/traffic-flow-ingest.js"
import { createMcpConnection, authenticateMcpToken, rotateConnectionToken, setConnectionStatus } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { decryptSecret } from "./security.js"
import type { ChangeEvidence } from "./action-types.js"
/** Root-owned disposable FULL-schema database only. The supplied application
* must expose the actual /api/traffic/flow/rebuild-facts route with JWT auth.
* This helper does not start/stop/restart a collector or perform network I/O. */
export async function acceptMcpFlowFactsRebuild(actor: AuthUser, app: FastifyInstance) {
assert.equal(process.env.MMAPP_OWN_MCP_FLOW_REBUILD, "true")
assert.equal(new URL(process.env.DATABASE_URL!).hostname, "127.0.0.1")
assert.equal(env.authRequired, true, "REST parity requires real owner-JWT authorization")
for (const table of ["flow_buckets", "flow_hour_facts", "flow_daily_facts"]) assert.equal(Number((await pool.query(`SELECT count(*) AS n FROM ${table}`)).rows[0].n), 0, "isolated empty traffic store required")
assert.equal(peekPendingFlows().length, 0)
assert.equal(getFlowWorkerHealth().alive, false, "fixture must use the existing main writer with no worker")
const listener = getFlowListenerState(), serverId = 198, userId = "mcp-rebuild-" + randomUUID(), connections: string[] = []
const protectionId = randomUUID(), bindingId = randomUUID(), jobs: string[] = []
let networkAttempts = 0
const savedFetch = globalThis.fetch
globalThis.fetch = (async () => { networkAttempts++; throw Error("MCP rebuild acceptance forbids network access") }) as typeof fetch
const wait = async (id: string) => {
for (const deadline = Date.now() + 60000; Date.now() < deadline;) {
const row = (await pool.query("SELECT state,verification,error_code FROM mcp_operations WHERE id=$1", [id])).rows[0]
if (!["pending", "running"].includes(row.state)) return row
await delay(25)
}
throw Error("MCP rebuild remains live; no replay is permitted")
}
const count = async (table: string) => Number((await pool.query(`SELECT count(*) AS n FROM ${table}`)).rows[0].n)
const source = async (id: string) => JSON.parse(await decryptSecret((await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [id])).rows[0].evidence.sealed)) as ChangeEvidence
try {
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers WHERE id=$1", [serverId])).rows[0].n, 0)
await pool.query("INSERT INTO servers(id,name,host,type,wan_uplinks) VALUES($1,'mcp-rebuild-fixture','203.0.113.198','jump-host',$2::jsonb)", [serverId, JSON.stringify([{ iface: "ether1", ip: "203.0.113.198" }])])
await pool.query("INSERT INTO app_users(id,login,name) VALUES($1,$1,'MCP rebuild fixture')", [userId])
await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type) VALUES($1,$2,$3,'gre-client','gre')", [bindingId, userId, serverId])
const now = new Date(), day = now.toISOString().slice(0, 10), minute = now.toISOString().slice(0, 16) + ":00Z"
await ensurePartitionFor(pool, "server_snapshots", "week", now)
await pool.query("INSERT INTO server_snapshots(server_id,polled_at,status,raw_interfaces) VALUES($1,$2,'online',$3::jsonb)", [serverId, now, JSON.stringify([{ name: "gre-client", type: "gre", ifindex: "10" }, { name: "ether1", type: "ether" }])])
await pool.query("INSERT INTO traffic_flow_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
await ensurePartitionFor(pool, "flow_buckets", "day", now)
await pool.query("INSERT INTO flow_buckets(server_id,bucket_at,src,dst,proto,src_port,dst_port,bytes,packets,in_iface,out_iface) VALUES($1,$2,'95.167.1.10','8.8.8.8',6,1234,443,10,1,'10','ether1')", [serverId, minute])
rememberServerIfaces(serverId, [{ name: "gre-client", ifindex: "10" }])
seedRipeCacheForTests({ prefix: "8.8.8.0/24", asn: 15169, country: "US", lat: null, lng: null, holder: "GOOGLE", ok: true, fetchedAt: Date.now() })
seedFlowCatalogForTests({ cidrs: [{ cidr: "8.8.8.0/24", purpose: "fixture-service" }] })
invalidateFlowCatalogCache(); await getServerCatalog(); await loadFlowTopology()
const ids = (await pool.query("SELECT id FROM servers ORDER BY id")).rows.map(row => Number(row.id))
const configuration = mcpConnectionCreateSchema.parse({ name: "MCP facts rebuild", mode: "autonomous", dangerousPolicy: "explicit-allow", autonomousDangerousOperationIds: ["traffic.flow.facts.rebuild"], permissions: ["mm:traffic:write"], expiresAt: new Date(Date.now() + 1800000).toISOString(), scope: { allowGlobal: true, serverIds: ids, operationIds: ["traffic.flow.facts.rebuild", "traffic.flow.facts.rebuild.reconcile"] }, limits: { callsPerMinute: 240, mutationsPerHour: 1000 } })
const access = await createMcpConnection(configuration, actor); connections.push(access.connection.id)
const principal = await authenticateMcpToken(access.token)
const prepare = () => invokeMcpTool(principal, "mm_netflow_facts_rebuild_plan", {}) as Promise<{ planId: string; state: string; changes: unknown }>
const beforeInvalid = await count("mcp_requests")
for (const input of [{ serverIds: ids }, { force: true }, { jobId: randomUUID() }, { jobId: undefined }]) await assert.rejects(invokeMcpTool(principal, "mm_netflow_facts_rebuild_plan", input))
assert.equal(await count("mcp_requests"), beforeInvalid)
const plan = await prepare(), evidence = await source(plan.planId), step = evidence.steps[0]
assert.equal(step.kind, "service"); if (step.kind !== "service") throw Error("service required")
const jobId = String(step.input.jobId); jobs.push(jobId)
assert.equal(plan.state, "awaiting-approval", "human approval is mandatory in autonomous mode")
assert.equal(evidence.mutationCost, 3)
assert.deepEqual([...new Set(evidence.objects.flatMap(object => object.kind === "server" ? [object.serverId] : []))], ids)
assert.equal((plan.changes as { steps: { input: { bindingCiphertext: string } }[] }).steps[0].input.bindingCiphertext, "[redacted]")
const beforeApproval = await count("flow_write_receipts")
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()))
assert.equal(await count("flow_write_receipts"), beforeApproval)
ingestParsedFlowsForServerForTests(serverId, [{ src: "95.167.1.10", dst: "8.8.8.8", proto: 6, srcPort: 1235, dstPort: 443, bytes: 5, packets: 2, inIface: "10", outIface: "ether1" }])
assert.equal(peekPendingFlows({ serverId }).length, 1)
await approvePlan(plan.planId, actor, true)
const operation = await applyPlan(principal, plan.planId, randomUUID()), finished = await wait(operation.id)
assert.equal(finished.state, "applied"); assert.equal(finished.verification, "passed")
assert.equal(peekPendingFlows({ serverId }).length, 0, "actual existing main writer drained")
const result = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=0", [operation.id])).rows[0].result
assert.deepEqual(result, { ok: true, buckets: 2, facts: 1, days: [day], jobId })
assert.equal(Number((await pool.query("SELECT sum(bytes) AS n FROM flow_daily_facts WHERE server_id=$1", [serverId])).rows[0].n), 15)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM events WHERE id=$1", [jobId])).rows[0].n, 1)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM flow_write_receipts WHERE batch_id=$1 AND kind='rebuild'", [jobId])).rows[0].n, 1)
assert.ok((await pool.query("SELECT count(*)::int AS n FROM flow_write_receipts WHERE kind='ingest'")).rows[0].n >= 1)
const checkpoint = { events: await count("events"), receipts: await count("flow_write_receipts") }
await pool.query("UPDATE mcp_operations SET state='unknown',verification='inconclusive' WHERE id=$1", [operation.id])
// New credentials on the same current connection can reconcile a historical
// publication. The old approved binding is not a live-authority fallback.
const rotated = await rotateConnectionToken(access.connection.id, actor)
const fresh = await authenticateMcpToken(rotated.token)
const reconciled = await invokeMcpTool(fresh, "mm_netflow_facts_rebuild_reconcile", { operationId: operation.id }) as { matchesDesiredState: boolean; matchesOriginalState: boolean; automaticRetry: boolean }
assert.equal(reconciled.matchesDesiredState, true); assert.equal(reconciled.matchesOriginalState, false); assert.equal(reconciled.automaticRetry, false)
assert.deepEqual({ events: await count("events"), receipts: await count("flow_write_receipts") }, checkpoint)
await pool.query("UPDATE mcp_plans SET operation_version=2 WHERE id=$1", [plan.planId])
await assert.rejects(invokeMcpTool(fresh, "mm_netflow_facts_rebuild_reconcile", { operationId: operation.id }))
await pool.query("UPDATE mcp_plans SET operation_version=1 WHERE id=$1", [plan.planId])
const other = await createMcpConnection({ ...configuration, name: "Other rebuild owner context" }, actor); connections.push(other.connection.id)
await assert.rejects(invokeMcpTool(await authenticateMcpToken(other.token), "mm_netflow_facts_rebuild_reconcile", { operationId: operation.id }))
const narrow = await createMcpConnection({ ...configuration, name: "Narrow rebuild", scope: { ...configuration.scope, serverIds: ids.filter(id => id !== serverId) } }, actor); connections.push(narrow.connection.id)
await assert.rejects(invokeMcpTool(await authenticateMcpToken(narrow.token), "mm_netflow_facts_rebuild_plan", {}))
await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2::jsonb,$3)", [protectionId, JSON.stringify({ kind: "database-partition", id: "flow_daily_facts/flow_daily_facts_d_20990101", protection: "deny" }), actor.id])
const protectedBefore = await count("flow_write_receipts")
await assert.rejects(invokeMcpTool(fresh, "mm_netflow_facts_rebuild_plan", {}))
assert.equal(await count("flow_write_receipts"), protectedBefore)
assert.equal((await invokeMcpTool(fresh, "mm_netflow_facts_rebuild_reconcile", { operationId: operation.id }) as { matchesDesiredState: boolean }).matchesDesiredState, true, "late protection does not erase a historical receipt")
await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protectionId])
const restBefore = await count("events")
const jwt = app.jwt.sign({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }, { iss: env.authIssuer, expiresIn: "10m" })
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/rebuild-facts", headers: { authorization: "Bearer " + jwt } })
assert.equal(response.statusCode, 200, response.body)
assert.deepEqual(response.json(), { ok: true, buckets: 2, facts: 1, days: [day] })
assert.equal(await count("events"), restBefore + 1, "shared service writes exactly one REST rebuild event")
assert.equal(getFlowWorkerHealth().alive, false); assert.deepEqual(getFlowListenerState(), listener); assert.equal(networkAttempts, 0)
console.log("MCP/REST flow rebuild: full schema, actual main strict drain, mandatory human approval, exact private receipt/public DTO, scope/protection/source-version/cross-connection fences, fresh-credential read-only reconciliation and JWT REST parity")
} finally {
globalThis.fetch = savedFetch
await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protectionId])
for (const id of connections) await setConnectionStatus(id, "revoked", actor)
// The runner owns the database lifecycle. Retain operation/event/receipt
// evidence; it must dispose of the private DB after this acceptance.
}
}
@@ -0,0 +1,166 @@
import assert from "node:assert/strict"
import { createDecipheriv, randomBytes, randomUUID } from "node:crypto"
import type { PoolClient } from "pg"
import { mcpConnectionPolicySchema } from "@mmapp/contracts/mcp"
import { createFlowFactsRebuildAdapter, flowFactsRebuildActionSchema, flowFactsRebuildReconcileSchema, publicFlowFactsRebuildReceipt } from "./flow-facts-rebuild-actions.js"
import { FlowFactsRebuildUnknown } from "../../services/traffic-flow-facts-rebuild-service.js"
import type { ActionContext } from "./action-types.js"
import type { ConnectionRow } from "./store.js"
import { fingerprint, redact, seal } from "./security.js"
import { publicMcpChangeEvidence } from "./routing-community-public.js"
function fixture() {
const key = randomBytes(32), principal = { connectionId: randomUUID(), credentialId: randomUUID(), credentialExpiresAt: new Date(Date.now() + 600000).toISOString() }
const connection: ConnectionRow = { id: principal.connectionId, name: "fixture", owner_id: randomUUID(), owner_permissions: ["mm:traffic:write"], generation: "fixture-generation", created_at: new Date(), updated_at: new Date(), last_used_at: null, owner_valid_until: new Date(Date.now() + 600000), owner_claims_issued_at: new Date(), policy: mcpConnectionPolicySchema.parse({ id: principal.connectionId, version: 1, status: "active", mode: "autonomous", expiresAt: principal.credentialExpiresAt, permissions: ["mm:traffic:write"], scope: { serverIds: [1, 2], operationIds: ["traffic.flow.facts.rebuild", "traffic.flow.facts.rebuild.reconcile"], allowGlobal: true } }) }
let catalog = [1, 2], configProof = "a".repeat(64), revoked = false, effects = 0, rebuildCalls = 0, reconcileCalls = 0, unknown = false, committed = true
const clients: unknown[] = [], queries: unknown[] = []
const operationId = randomUUID()
let sourceRow: Record<string, unknown> | undefined
const query = async (sql: unknown, args?: unknown[]) => {
queries.push(sql)
if (String(sql).includes("FROM mcp_operations")) return { rows: sourceRow && args?.[0] === operationId && args?.[1] === sourceRow.connection_id ? [sourceRow] : [] }
assert.equal(sql, "SELECT id FROM servers ORDER BY id LIMIT 1001"); return { rows: catalog.map(id => ({ id })) }
}
const client = { query } as unknown as Pick<PoolClient, "query">, owned = { query } as unknown as PoolClient
let saved: Record<string, unknown> | null = null, duringWrite: (() => void) | undefined, afterWrite: (() => void) | undefined
const open = async (value: string) => { const bytes = Buffer.from(value, "base64"), cipher = createDecipheriv("aes-256-gcm", key, bytes.subarray(0, 12)); cipher.setAuthTag(bytes.subarray(12, 28)); return Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString("utf8") }
const adapter = createFlowFactsRebuildAdapter({
client, seal: async value => seal(value, key), open, verifiedAccess: async () => {},
connection: async (_principal, actual) => { clients.push(actual); if (revoked) throw Error("revoked"); return connection },
configuration: async (ids, authorize, actual) => { assert.deepEqual(ids, [1, 2]); assert.ok(actual); await authorize(); return { proof: configProof, serverIds: [...ids], objects: [{ kind: "netflow-facts", id: "global" }, { kind: "database-partition", id: "flow_daily_facts/flow_daily_facts_d_20990101" }] } },
drain: async (authorize, ids) => { assert.deepEqual(ids, [1, 2]); await authorize(); effects++ },
rebuild: async (authority, input = {}) => {
rebuildCalls++; assert.equal(input.expectedConfigurationProof, configProof)
await authority.authorize(owned); duringWrite?.(); await authority.authorize(owned)
await authority.drainExisting(() => authority.authorize(owned), authority.serverIds)
effects++; saved = { ok: true, buckets: 2, facts: 1, days: ["2026-10-23"], jobId: input.jobId!, configurationProof: configProof, classifierProof: "b".repeat(64), resultProof: "c".repeat(64) }
if (unknown) throw new FlowFactsRebuildUnknown(input.jobId!, configProof)
afterWrite?.()
return saved as Awaited<ReturnType<typeof import("../../services/traffic-flow-facts-rebuild-service.js").rebuildFlowFactsSafely>>
},
reconcile: async (_job, _proof, authority) => { reconcileCalls++; await authority.authorize(owned); return { committed, result: committed ? saved : null } },
})
const context: ActionContext = { principal, connection: structuredClone(connection) }
const prepare = async () => {
const evidence = await adapter.prepare({}, context), step = evidence.steps[0]
assert.equal(step.kind, "service"); if (step.kind !== "service") throw Error("service expected")
sourceRow = { id: operationId, state: "unknown", connection_id: connection.id, source_action: "traffic.flow.facts.rebuild", source_version: 1, evidence: { sealed: seal(JSON.stringify(evidence), key) } }
return { evidence, input: step.input }
}
return { adapter, context, connection, prepare, open, owned, clients, queries, operationId,
source: (patch: Record<string, unknown>) => { Object.assign(sourceRow!, patch) },
stats: () => ({ effects, rebuildCalls, reconcileCalls }),
catalog: (ids: number[]) => { catalog = ids }, config: () => { configProof = "d".repeat(64) }, revoke: () => { revoked = true },
setUnknown: () => { unknown = true }, setAbsent: () => { committed = false }, during: (fn: () => void) => { duringWrite = fn },
after: (fn: () => void) => { afterWrite = fn },
}
}
assert.deepEqual(flowFactsRebuildActionSchema.parse({}), {})
assert.equal(flowFactsRebuildReconcileSchema.safeParse({ jobId: randomUUID() }).success, false)
for (const value of [{ serverIds: undefined }, { jobId: randomUUID() }, { force: true }]) assert.equal(flowFactsRebuildActionSchema.safeParse(value).success, false)
{
const f = fixture(), { evidence, input } = await f.prepare()
assert.equal(evidence.requiresHuman, true, "autonomous mode still requires human approval")
assert.equal(evidence.mutationCost, 3)
assert.deepEqual(evidence.objects.filter(object => object.kind === "server").map(object => object.serverId), [1, 2])
assert.ok(evidence.objects.some(object => object.kind === "database-partition" && object.id.includes("20990101")), "future protected leaves remain in the footprint")
const visible = redact(publicMcpChangeEvidence("traffic.flow.facts.rebuild", evidence)) as { steps: { input: Record<string, unknown> }[] }
assert.equal(visible.steps[0].input.bindingCiphertext, "[redacted]")
for (const key of ["principal", "policyProof", "ownerProof", "configurationProof", "classifierProof", "resultProof"]) assert.ok(!JSON.stringify(visible).includes(key))
const result = await f.adapter.service.execute(input)
assert.deepEqual(Object.keys(result as object).sort(), ["buckets", "days", "facts", "jobId", "ok"])
assert.equal(await f.adapter.service.verify!(input, result), true)
assert.equal(await f.adapter.service.verify!(input, { ...(result as object), configProof: "leak" }), false)
assert.ok(f.clients.includes(f.owned), "authority uses the supplied transaction client")
assert.equal((await f.adapter.readReconciliation({ operationId: f.operationId }, f.context)).matchesDesiredState, true)
assert.equal(f.stats().rebuildCalls, 1, "verify and reconcile never execute the rebuild")
}
for (const mutate of [(input: Record<string, unknown>) => { input.jobId = randomUUID() }, (input: Record<string, unknown>) => { input.serverIds = [1] }, (input: Record<string, unknown>) => { input.bindingCiphertext = "tampered" }]) {
const f = fixture(), { input } = await f.prepare(), before = f.queries.length
mutate(input); await assert.rejects(f.adapter.service.execute(input)); assert.equal(f.queries.length, before); assert.equal(f.stats().effects, 0)
}
for (const change of [
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.allowGlobal = false },
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.serverIds = [1] },
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.operationIds = [] },
(f: ReturnType<typeof fixture>) => { f.connection.owner_permissions.push("mm:network:read") },
(f: ReturnType<typeof fixture>) => { f.connection.policy.permissions = [] },
(f: ReturnType<typeof fixture>) => { f.connection.policy.status = "paused" },
(f: ReturnType<typeof fixture>) => { f.connection.policy.expiresAt = "2000-01-01T00:00:00.000Z" },
(f: ReturnType<typeof fixture>) => { f.connection.owner_id = randomUUID() },
(f: ReturnType<typeof fixture>) => { f.connection.generation = "changed" },
(f: ReturnType<typeof fixture>) => f.catalog([1, 2, 3]),
(f: ReturnType<typeof fixture>) => f.config(), // Includes late protections in config-v3.
(f: ReturnType<typeof fixture>) => f.revoke(),
]) {
const f = fixture(), { input } = await f.prepare(); change(f)
await assert.rejects(f.adapter.service.current(input)); await assert.rejects(f.adapter.service.execute(input)); assert.equal(f.stats().effects, 0)
}
{
const f = fixture(); f.context.principal.credentialExpiresAt = "2000-01-01T00:00:00.000Z"
await assert.rejects(f.prepare()); assert.equal(f.stats().effects, 0)
}
{
const f = fixture(), { input } = await f.prepare()
f.during(() => f.connection.owner_permissions.push("mm:network:read"))
await assert.rejects(f.adapter.service.execute(input)); assert.ok(f.clients.includes(f.owned)); assert.equal(f.stats().effects, 0, "late authority changes stop before drain/publication")
}
{
const f = fixture(), { input } = await f.prepare(); f.setUnknown()
await assert.rejects(f.adapter.service.execute(input), (error: unknown) => (error as { code?: string }).code === "MUTATION_OUTCOME_UNKNOWN")
const count = f.stats().rebuildCalls
assert.deepEqual(await f.adapter.service.reconcile!(input, async () => {}), { matchesDesiredState: true, matchesOriginalState: false })
f.setAbsent()
assert.deepEqual(await f.adapter.service.reconcile!(input, async () => {}), { matchesDesiredState: false, matchesOriginalState: false })
assert.equal(f.stats().rebuildCalls, count)
f.revoke(); await assert.rejects(f.adapter.readReconciliation({ operationId: f.operationId }, f.context)); assert.equal(f.stats().rebuildCalls, count)
}
for (const mutate of [
(f: ReturnType<typeof fixture>) => f.source({ source_action: "traffic.flow.purge" }),
(f: ReturnType<typeof fixture>) => f.source({ source_version: 2 }),
(f: ReturnType<typeof fixture>) => f.source({ state: "running" }),
(f: ReturnType<typeof fixture>) => f.source({ state: "pending" }),
(f: ReturnType<typeof fixture>) => f.source({ connection_id: randomUUID() }),
(f: ReturnType<typeof fixture>) => f.source({ evidence: { sealed: "tampered" } }),
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.operationIds = ["traffic.flow.facts.rebuild.reconcile"]; f.context.connection = structuredClone(f.connection) },
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.serverIds = [1]; f.context.connection = structuredClone(f.connection) },
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.allowGlobal = false; f.context.connection = structuredClone(f.connection) },
(f: ReturnType<typeof fixture>) => { f.connection.policy.status = "paused"; f.context.connection = structuredClone(f.connection) },
(f: ReturnType<typeof fixture>) => { f.connection.owner_permissions = []; f.context.connection = structuredClone(f.connection) },
]) {
const f = fixture(); await f.prepare(); mutate(f)
await assert.rejects(f.adapter.readReconciliation({ operationId: f.operationId }, f.context))
assert.equal(f.stats().rebuildCalls, 0); assert.equal(f.stats().reconcileCalls, 0)
}
{
const f = fixture(), { input } = await f.prepare(); await f.adapter.service.execute(input)
// Fresh current credentials may read historical publication even after the
// approved source config or the previous credential's deadline has changed.
f.config()
const realNow = Date.now
try {
Date.now = () => realNow() + 700000
f.connection.policy.expiresAt = new Date(Date.now() + 900000).toISOString()
f.connection.owner_valid_until = new Date(Date.now() + 900000)
f.context.connection = structuredClone(f.connection)
f.context.principal = { ...f.context.principal, credentialId: randomUUID(), credentialExpiresAt: new Date(Date.now() + 900000).toISOString() }
assert.ok(Date.parse(JSON.parse(await f.open(String(input.bindingCiphertext))).principal.credentialExpiresAt) < Date.now())
assert.equal((await f.adapter.readReconciliation({ operationId: f.operationId }, f.context)).matchesDesiredState, true)
} finally { Date.now = realNow }
let calls = 0
await assert.rejects(f.adapter.service.reconcile!(input, async () => { if (++calls > 1) throw Error("fresh grant revoked") }))
assert.equal(f.stats().rebuildCalls, 1)
}
{
const f = fixture(), { input } = await f.prepare(); f.after(() => f.revoke())
await assert.rejects(f.adapter.service.execute(input), (error: unknown) => (error as { code?: string }).code === "MUTATION_OUTCOME_UNKNOWN")
assert.equal(f.stats().effects, 2, "publication was acknowledged before the final access check failed")
assert.equal(f.stats().rebuildCalls, 1)
}
{
const value = { ok: true, buckets: 1, facts: 1, days: ["2026-10-23"], jobId: randomUUID(), configurationProof: "a".repeat(64), classifierProof: "b".repeat(64), resultProof: "c".repeat(64) }
for (const patch of [{ jobId: randomUUID() }, { configurationProof: "d".repeat(64) }, { days: ["2026-02-30"] }, { days: ["2026-10-23", "2026-10-23"] }, { facts: 2 }, { classifierProof: undefined }, { raw: "secret" }]) assert.throws(() => publicFlowFactsRebuildReceipt({ ...value, ...patch }, value.jobId, value.configurationProof))
assert.equal(fingerprint(publicFlowFactsRebuildReceipt(value, value.jobId, value.configurationProof)), fingerprint({ ok: true, buckets: 1, facts: 1, days: ["2026-10-23"], jobId: value.jobId }))
}
console.log("MCP flow facts rebuild: strict schema, encrypted job/catalog binding, public redaction, owned-client authority, late revoke/protection/source fences, exact receipts and reconciliation without replay")
@@ -0,0 +1,163 @@
import { randomUUID } from "node:crypto"
import type { PoolClient } from "pg"
import { z } from "zod"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
import { drainExistingTrafficFlowWriters } from "../../services/traffic-flow-ingest.js"
import { readFactRebuildConfiguration, rebuildFlowFactsSafely, reconcileFlowFactsRebuild, FlowFactsRebuildUnknown } from "../../services/traffic-flow-facts-rebuild-service.js"
import { effectiveMcpConnection } from "./store.js"
import { encryptSecret, decryptSecret, fingerprint, McpError } from "./security.js"
import { registerMcpService, type McpServiceHandler } from "./service-registry.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
import type { add } from "./actions.js"
const operation = "traffic.flow.facts.rebuild"
const reconcileOperation = "traffic.flow.facts.rebuild.reconcile"
export const flowFactsRebuildActionSchema = z.object({}).strict()
export const flowFactsRebuildReconcileSchema = z.object({ operationId: z.string().uuid() }).strict()
const idsSchema = z.array(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)).max(1000).refine(ids => new Set(ids).size === ids.length && ids.every((id, index) => index === 0 || id > ids[index - 1]))
const proofSchema = z.string().regex(/^[a-f0-9]{64}$/)
const bindingSchema = z.object({
principal: z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string().datetime() }).strict(),
ownerId: z.string().min(1), ownerProof: proofSchema, policyProof: proofSchema,
connectionGeneration: z.string().min(1), jobId: z.string().uuid(), serverIds: idsSchema, configurationProof: proofSchema,
}).strict()
const inputSchema = z.object({ jobId: z.string().uuid(), serverIds: idsSchema, bindingCiphertext: z.string().min(1).max(262144) }).strict()
type Binding = z.infer<typeof bindingSchema>
const conflict = () => new McpError("STATE_CONFLICT", "Источник или допуск пересчёта NetFlow изменились; подготовьте новый план", 409)
const daySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(day => Number.isFinite(Date.parse(day + "T00:00:00Z")) && new Date(day + "T00:00:00Z").toISOString().slice(0, 10) === day)
const receiptSchema = z.object({ ok: z.literal(true), buckets: z.number().int().min(0).max(1000000), facts: z.number().int().min(0), days: z.array(daySchema).max(1000), jobId: z.string().uuid(), configurationProof: proofSchema, classifierProof: proofSchema, resultProof: proofSchema }).strict()
/** Durable receipts stay private. The model only receives these five fields. */
export function publicFlowFactsRebuildReceipt(result: unknown, jobId: string, configurationProof: string) {
const value = receiptSchema.parse(result)
if (value.jobId !== jobId || value.configurationProof !== configurationProof || value.facts > value.buckets || new Set(value.days).size !== value.days.length || fingerprint([...value.days].sort()) !== fingerprint(value.days) || (value.buckets === 0 ? value.facts !== 0 || value.days.length !== 0 : value.days.length === 0)) throw conflict()
return { ok: true as const, buckets: value.buckets, facts: value.facts, days: [...value.days], jobId: value.jobId }
}
const production = {
client: pool as Pick<PoolClient, "query">,
connection: effectiveMcpConnection,
configuration: readFactRebuildConfiguration,
rebuild: rebuildFlowFactsSafely,
reconcile: reconcileFlowFactsRebuild,
drain: drainExistingTrafficFlowWriters,
verifiedAccess: assertVerifiedConfigurationAccess,
seal: encryptSecret, open: decryptSecret,
}
/** Injectable application boundaries permit model checks without a database or
* collector. Production always uses the shared transaction/drain implementation. */
export function createFlowFactsRebuildAdapter(overrides: Partial<typeof production> = {}) {
const deps = { ...production, ...overrides }
const catalog = async (client: Pick<PoolClient, "query"> = deps.client) => idsSchema.parse((await client.query("SELECT id FROM servers ORDER BY id LIMIT 1001")).rows.map(row => Number(row.id)))
const authorize = async (binding: Binding, client: Pick<PoolClient, "query"> = deps.client, verified = false) => {
if (verified) await deps.verifiedAccess()
if (Date.parse(binding.principal.credentialExpiresAt) <= Date.now()) throw new McpError("UNAUTHORIZED", "Срок credential истёк", 401)
const connection = await deps.connection(binding.principal, client)
if (connection.policy.status !== "active" || Date.parse(connection.policy.expiresAt) <= Date.now() || connection.owner_valid_until.getTime() <= Date.now()) throw new McpError("UNAUTHORIZED", "Допуск пересчёта недействителен", 401)
if (connection.owner_id !== binding.ownerId || connection.policy.mode === "observer" || !connection.policy.scope.allowGlobal || !connection.policy.scope.operationIds.includes(operation) || binding.serverIds.some(id => !connection.policy.scope.serverIds.includes(id)) || !hasPermission(connection.policy.permissions, "mm:traffic:write") || !hasPermission(connection.owner_permissions, "mm:traffic:write")) throw new McpError("PERMISSION_DENIED", "Глобальный пересчёт NetFlow больше не разрешён", 403)
if (fingerprint(connection.policy) !== binding.policyProof || fingerprint(connection.owner_permissions) !== binding.ownerProof || connection.generation !== binding.connectionGeneration || fingerprint(await catalog(client)) !== fingerprint(binding.serverIds)) throw conflict()
}
const request = async (input: Record<string, unknown>) => {
const value = inputSchema.parse(input), binding = bindingSchema.parse(JSON.parse(await deps.open(value.bindingCiphertext)))
if (value.jobId !== binding.jobId || fingerprint(value.serverIds) !== fingerprint(binding.serverIds)) throw conflict()
return binding
}
const source = async (binding: Binding, verified = false) => {
const check = () => authorize(binding, deps.client, verified)
const current = await deps.configuration(binding.serverIds, check, deps.client)
if (current.proof !== binding.configurationProof) throw conflict()
await check()
return current
}
const receipt = async (binding: Binding, externalAuthorize: () => Promise<void> = async () => {}, freshAuthority = false) => {
const check = async (client?: PoolClient) => { await externalAuthorize(); if (!freshAuthority) await authorize(binding, client) }
if (!freshAuthority) await source(binding)
const saved = await deps.reconcile(binding.jobId, binding.configurationProof, { authorize: check })
await externalAuthorize(); if (!freshAuthority) await source(binding)
return saved.committed ? publicFlowFactsRebuildReceipt(saved.result, binding.jobId, binding.configurationProof) : null
}
const service: McpServiceHandler = {
current: async input => {
const binding = await request(input); await source(binding)
return { jobId: binding.jobId, serverIds: binding.serverIds, configurationUnchanged: true }
},
execute: async input => {
const binding = await request(input)
await source(binding, true)
let publicationConfirmed = false
try {
const result = await deps.rebuild({ serverIds: binding.serverIds, authorize: client => authorize(binding, client, true), drainExisting: deps.drain }, { jobId: binding.jobId, expectedConfigurationProof: binding.configurationProof })
publicationConfirmed = true
await source(binding, true)
return publicFlowFactsRebuildReceipt(result, binding.jobId, binding.configurationProof)
} catch (error) {
if (publicationConfirmed || error instanceof FlowFactsRebuildUnknown || (error as { code?: string } | null)?.code === "FLOW_COMMIT_UNKNOWN") throw new McpError("MUTATION_OUTCOME_UNKNOWN", "Результат пересчёта не подтверждён. Разрешена только сверка; повтор запрещён", 503)
throw error
}
},
verify: async (input, result) => {
const saved = await receipt(await request(input))
return saved !== null && fingerprint(saved) === fingerprint(result)
},
reconcile: async (input, externalAuthorize) => {
await externalAuthorize()
const saved = await receipt(await request(input), externalAuthorize, true)
// An absent publication receipt cannot prove that earlier strict drains
// had no effects. Never turn absence into a retry or an inverse.
return { matchesDesiredState: saved !== null, matchesOriginalState: false }
},
}
const prepare = async (input: Record<string, unknown>, context: ActionContext): Promise<ChangeEvidence> => {
flowFactsRebuildActionSchema.parse(input); context.signal?.throwIfAborted()
const serverIds = await catalog(), jobId = randomUUID()
const binding: Binding = { principal: context.principal, ownerId: context.connection.owner_id, ownerProof: fingerprint(context.connection.owner_permissions), policyProof: fingerprint(context.connection.policy), connectionGeneration: context.connection.generation, jobId, serverIds, configurationProof: "0".repeat(64) }
const check = async () => { context.signal?.throwIfAborted(); await authorize(binding) }
const initial = await deps.configuration(serverIds, check, deps.client)
binding.configurationProof = initial.proof
await source(binding); context.signal?.throwIfAborted()
const sealed = { jobId, serverIds, bindingCiphertext: await deps.seal(JSON.stringify(bindingSchema.parse(binding))) }
return { target: { scope: "global" }, objects: [...serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId })), ...initial.objects], snapshots: [], before: { jobId, serverIds, configurationUnchanged: true }, requiresHuman: true, mutationCost: 3,
steps: [{ kind: "service", service: operation, input: sealed }], warnings: [
"Глобальный пересчёт фактов из всех сохранённых flow buckets требует прав на полный текущий каталог серверов. Существующие факты затронутых дней будут заменены; обратного шага нет.",
"Используются текущая топология и сохранённые данные классификаторов; внешнее enrichment не запускается. Изменение настроек, источников или защит после подтверждения останавливает пересчёт.",
"Существующий collector продолжает обычный приём. Перед публикацией выполняется строгий drain существующих main/worker writers без запуска, остановки или перезапуска collector.",
"Бюджет — до трёх транзакций: main drain, worker drain и атомарная публикация фактов с квитанцией. Занятая транзакция или неизвестный COMMIT не повторяются автоматически. При unknown разрешена только read-only сверка этого jobId.",
] }
}
const readReconciliation = async (input: Record<string, unknown>, context: ActionContext) => {
const { operationId } = flowFactsRebuildReconcileSchema.parse(input)
const rows = (await deps.client.query(`SELECT o.id,o.state,o.connection_id,p.operation_id AS source_action,p.operation_version AS source_version,p.evidence
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 LIMIT 2`, [operationId, context.connection.id])).rows
if (rows.length !== 1) throw new McpError("NOT_FOUND", "Исходная операция пересчёта не найдена", 404)
const row = rows[0]
if (row.source_action !== operation || row.source_version !== 1 || row.connection_id !== context.connection.id) throw conflict()
if (!["applied", "partial", "unknown", "failed", "cancelled", "rejected", "interrupted"].includes(row.state)) throw new McpError("STATE_CONFLICT", "Исходная операция ещё не завершена; повтор запрещён", 409)
if (!row.evidence || typeof row.evidence.sealed !== "string") throw conflict()
const evidence = JSON.parse(await deps.open(row.evidence.sealed)) as ChangeEvidence
if (evidence.target?.scope !== "global" || evidence.steps?.length !== 1 || evidence.steps[0].kind !== "service" || evidence.steps[0].service !== operation) throw conflict()
const binding = await request(evidence.steps[0].input)
if (fingerprint(evidence.before) !== fingerprint({ jobId: binding.jobId, serverIds: binding.serverIds, configurationUnchanged: true })) throw conflict()
const freshAuthorize = async () => {
context.signal?.throwIfAborted()
if (Date.parse(context.principal.credentialExpiresAt) <= Date.now()) throw new McpError("UNAUTHORIZED", "Срок credential истёк", 401)
const current = await deps.connection(context.principal, deps.client)
if (current.policy.status !== "active" || Date.parse(current.policy.expiresAt) <= Date.now() || current.owner_valid_until.getTime() <= Date.now()) throw new McpError("UNAUTHORIZED", "Допуск сверки недействителен", 401)
if (binding.principal.connectionId !== current.id || current.id !== context.connection.id || current.owner_id !== binding.ownerId || current.policy.mode === "observer" || !current.policy.scope.allowGlobal || ![operation, reconcileOperation].every(id => current.policy.scope.operationIds.includes(id)) || binding.serverIds.some(id => !current.policy.scope.serverIds.includes(id)) || !hasPermission(current.owner_permissions, "mm:traffic:write") || !hasPermission(current.policy.permissions, "mm:traffic:write")) throw new McpError("PERMISSION_DENIED", "Нет актуального допуска к исходному глобальному пересчёту", 403)
if (fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions) || current.generation !== context.connection.generation) throw conflict()
}
await freshAuthorize()
const saved = await receipt(binding, freshAuthorize, true)
return { operationId, jobId: binding.jobId, state: String(row.state), matchesDesiredState: saved !== null, matchesOriginalState: false, automaticRetry: false }
}
return { prepare, service, readReconciliation }
}
export function registerFlowFactsRebuildActions(register: typeof add) {
const adapter = createFlowFactsRebuildAdapter()
registerMcpService(operation, adapter.service)
register(operation, "mm_netflow_facts_rebuild_plan", "Пересчитать факты NetFlow из сохранённых сессий", "traffic", "mm:traffic:write", "write", "global", flowFactsRebuildActionSchema, { version: 1, prepare: adapter.prepare })
register(reconcileOperation, "mm_netflow_facts_rebuild_reconcile", "Сверить квитанцию пересчёта NetFlow без повторной записи", "traffic", "mm:traffic:write", "read", "global", flowFactsRebuildReconcileSchema, { version: 1, read: adapter.readReconciliation })
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict"
// This runner is only admitted by the dedicated disposable-cluster harness.
const endpoint = new URL(process.env.DATABASE_URL ?? "postgresql://invalid")
assert.equal(process.env.MMAPP_OWN_MCP_FLOW_REBUILD, "true")
assert.equal(endpoint.hostname, "127.0.0.1")
assert.equal(endpoint.pathname, "/postgres")
assert.match(process.env.MCP_STATE_DIR ?? "", /mm-mcp-test-/)
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "mcp-owned-flow-rebuild-acceptance-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
const { pool, closePool } = await import("../../db/index.js")
const { applySqlMigrations } = await import("../../db/migrate.js")
const { updateMcpSettings } = await import("./store.js")
const { buildApp } = await import("../../index.js")
const actor = { id: "flow-rebuild-acceptance-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:traffic:admin", "mm:network:admin"], expiresAt: Date.now() + 3600000 }
let app: Awaited<ReturnType<typeof buildApp>> | undefined
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await pool.query("INSERT INTO geoip_settings(id,enabled) VALUES(1,false) ON CONFLICT(id) DO UPDATE SET enabled=false")
await pool.query("INSERT INTO traffic_flow_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
await updateMcpSettings({ enabled: true }, actor)
app = await buildApp({ startScheduler: false })
await app.ready()
await (await import("./flow-facts-rebuild-acceptance.js")).acceptMcpFlowFactsRebuild(actor, app)
} finally {
await app?.close()
await closePool()
}
@@ -0,0 +1,79 @@
import assert from "node:assert/strict"
import { randomUUID, randomBytes, createDecipheriv, createHmac } from "node:crypto"
import { mcpConnectionPolicySchema } from "@mmapp/contracts/mcp"
import { createFlowHostKeysAdapter, flowHostKeysActionSchema } from "./flow-host-keys-actions.js"
import { fingerprint, seal, redact } from "./security.js"
import type { ConnectionRow } from "./store.js"
import type { FlowSettingsConfiguration } from "../../services/traffic-flow-settings-execution.js"
import { publicMcpChangeEvidence } from "./routing-community-public.js"
function fixture(complete = false) {
const key = randomBytes(32), principal = { connectionId: randomUUID(), credentialId: randomUUID(), credentialExpiresAt: new Date(Date.now() + 600000).toISOString() }
const connection: ConnectionRow = { id: principal.connectionId, name: "fixture", owner_id: "owner", owner_permissions: ["mm:traffic:write"], generation: "generation", created_at: new Date(), updated_at: new Date(), last_used_at: null, owner_valid_until: new Date(Date.now() + 600000), owner_claims_issued_at: new Date(), policy: mcpConnectionPolicySchema.parse({ id: principal.connectionId, version: 1, status: "active", mode: "autonomous", expiresAt: principal.credentialExpiresAt, permissions: ["mm:traffic:write"], scope: { serverIds: [1, 2], operationIds: ["traffic.flow.settings.generate-keys"], allowGlobal: true } }) }
const configuration: FlowSettingsConfiguration = { id: 1, hostPublicKey: complete ? "public-existing" : "", hostPrivateKey: complete ? "PRIVATE-EXISTING" : "", future: "opaque", peersJson: [{ privateOpaque: "retained" }] }
let catalog = [1, 2], protections: Record<string, unknown>[] = [], writes = 0, verified: Record<string, unknown>[] = []
let beforeWrite = () => {}, afterWrite = () => {}
const proof = async (value: unknown) => createHmac("sha256", key).update(fingerprint(value)).digest("hex")
const adapter = createFlowHostKeysAdapter({
proof, seal: async value => seal(value, key),
open: async value => { const data = Buffer.from(value, "base64"), c = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)); c.setAuthTag(data.subarray(12, 28)); return Buffer.concat([c.update(data.subarray(28)), c.final()]).toString() },
connection: async () => connection, verifiedAccess: async () => {},
query: async sql => sql.includes("mcp_operation_steps") ? verified : sql.includes("mcp_protections") ? protections : catalog.map(id => ({ id })),
read: async authorize => { await authorize?.(); return structuredClone(configuration) },
generate: async control => {
await control?.authorize?.(); beforeWrite(); await control?.authorize?.()
assert.deepEqual(control?.expected?.before, configuration)
const created = !configuration.hostPublicKey || !configuration.hostPrivateKey
if (created) { configuration.hostPublicKey = "public-created"; configuration.hostPrivateKey = "PRIVATE-CREATED"; writes++ }
afterWrite(); await control?.authorize?.()
return { ok: true, receiptId: control!.receiptId!, created, publicKey: configuration.hostPublicKey, afterProof: await proof(configuration), listenerRestarted: false, privateMaterialExported: false, automaticReplay: false }
},
})
return { adapter, connection, principal, prepare: () => adapter.prepare({}, { connection: structuredClone(connection), principal }),
get configuration() { return configuration }, get writes() { return writes },
catalog: () => { catalog = [1] }, protect: () => { protections = [{ object_ref: { kind: "netflow-settings", id: "1", protection: "deny" }, revision: "2" }] },
before: (fn: () => void) => { beforeWrite = fn }, after: (fn: () => void) => { afterWrite = fn },
verified: (value: Record<string, unknown>) => { verified = [value] } }
}
const input = (evidence: Awaited<ReturnType<ReturnType<typeof fixture>["prepare"]>>) => { assert.equal(evidence.steps[0].kind, "service"); if (evidence.steps[0].kind !== "service") throw Error(); return evidence.steps[0].input }
for (const raw of [{ force: true }, { serverId: 1 }, { includePrivateKey: true }, { receiptId: randomUUID() }]) assert.equal(flowHostKeysActionSchema.safeParse(raw).success, false)
for (const complete of [false, true]) {
const f = fixture(complete), evidence = await f.prepare(), request = input(evidence)
assert.equal(evidence.requiresHuman, true); assert.equal(evidence.mutationCost, 1)
assert.deepEqual(evidence.objects.filter(ref => ref.kind === "server").map(ref => ref.serverId), [1, 2])
const visible = JSON.stringify(redact(publicMcpChangeEvidence("traffic.flow.settings.generate-keys", evidence)))
for (const secret of ["PRIVATE-EXISTING", "privateOpaque", "beforeProof", "ownerProof", "principal"]) assert.ok(!visible.includes(secret))
const receipt = await f.adapter.service.execute(request)
assert.equal(f.writes, complete ? 0 : 1)
assert.equal(await f.adapter.service.verify!(request, receipt), true)
assert.equal(await f.adapter.service.verify!(request, { ...receipt as object, privateMaterialExported: true }), false)
assert.equal((await f.adapter.service.reconcile!(request, async () => {})).matchesDesiredState, false, "matching row without VERIFIED is insufficient")
f.verified({ connection_id: f.principal.connectionId, state: "verified", intended_state: redact({ kind: "service", service: "traffic.flow.settings.generate-keys", input: request }), result: receipt })
assert.equal((await f.adapter.service.reconcile!(request, async () => {})).matchesDesiredState, true)
assert.ok(!JSON.stringify(receipt).includes("PRIVATE"))
}
for (const change of [
(f: ReturnType<typeof fixture>) => { f.connection.policy.status = "paused" },
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.allowGlobal = false },
(f: ReturnType<typeof fixture>) => { f.connection.policy.scope.serverIds = [1] },
(f: ReturnType<typeof fixture>) => { f.connection.owner_permissions = [] },
(f: ReturnType<typeof fixture>) => { f.connection.generation = "changed" },
(f: ReturnType<typeof fixture>) => { f.configuration.future = "drift" },
(f: ReturnType<typeof fixture>) => f.catalog(), (f: ReturnType<typeof fixture>) => f.protect(),
]) {
const f = fixture(), request = input(await f.prepare()); change(f)
await assert.rejects(f.adapter.service.execute(request)); assert.equal(f.writes, 0)
}
{
const f = fixture(), request = input(await f.prepare()); f.before(() => f.protect())
await assert.rejects(f.adapter.service.execute(request)); assert.equal(f.writes, 0)
}
{
const f = fixture(), request = input(await f.prepare()); f.after(() => { f.connection.policy.status = "paused" })
await assert.rejects(f.adapter.service.execute(request)); assert.equal(f.writes, 1)
}
{
const f = fixture(), request = input(await f.prepare()); request.receiptId = randomUUID()
await assert.rejects(f.adapter.service.execute(request)); assert.equal(f.writes, 0)
}
console.log("NetFlow host key adapter: human/global/full catalog, private binding, source/protection/revocation, public-only receipt, no VERIFIED adoption and no listener; injected dependencies only")
@@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto"
import { z } from "zod"
import { pool } from "../../db/index.js"
import { hasPermission } from "../../lib/permissions.js"
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
import { generateFlowHostKeys, readFlowHostKeysConfiguration, flowSettingsPrivateProof } from "../../services/traffic-flow-host-keys-service.js"
import { effectiveMcpConnection } from "./store.js"
import { encryptSecret, decryptSecret, fingerprint, redact, McpError } from "./security.js"
import { registerMcpService, type McpServiceHandler } from "./service-registry.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
import type { FlowSettingsPlan } from "../../services/traffic-flow-settings-execution.js"
import type { add } from "./actions.js"
const operation = "traffic.flow.settings.generate-keys"
export const flowHostKeysActionSchema = z.object({}).strict()
const hash = z.string().regex(/^[a-f0-9]{64}$/)
const ids = z.array(z.number().int().positive().safe()).max(1000).refine(value => value.every((id, index) => index === 0 || value[index - 1] < id))
const bindingSchema = z.object({
operation: z.literal(operation), version: z.literal(1), receiptId: z.string().uuid(),
principal: z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string().datetime() }).strict(),
ownerId: z.string().min(1), ownerProof: hash, policyProof: hash, generation: z.string().min(1), serverIds: ids,
beforeProof: hash, protectionProof: hash,
before: z.record(z.string(), z.unknown()).refine(value => typeof value.hostPrivateKey === "string" && typeof value.hostPublicKey === "string"),
}).strict()
type Binding = z.infer<typeof bindingSchema>
const inputSchema = z.object({ receiptId: z.string().uuid(), bindingCiphertext: z.string().min(1).max(524288) }).strict()
const receiptSchema = z.object({ ok: z.literal(true), receiptId: z.string().uuid(), created: z.boolean(), publicKey: z.string().min(1).max(100), afterProof: hash,
privateMaterialExported: z.literal(false), listenerRestarted: z.literal(false), automaticReplay: z.literal(false) }).strict()
const conflict = () => new McpError("STATE_CONFLICT", "Конфигурация, защиты или допуск генерации ключей NetFlow изменились", 409)
const production = {
connection: effectiveMcpConnection, read: readFlowHostKeysConfiguration, generate: generateFlowHostKeys,
seal: encryptSecret, open: decryptSecret, proof: flowSettingsPrivateProof, verifiedAccess: assertVerifiedConfigurationAccess,
query: async (sql: string, values: unknown[] = []) => (await pool.query(sql, values)).rows as Record<string, unknown>[],
}
export function createFlowHostKeysAdapter(overrides: Partial<typeof production> = {}) {
const deps = { ...production, ...overrides }
const catalog = async () => ids.parse((await deps.query("SELECT id FROM servers ORDER BY id LIMIT 1001")).map(row => Number(row.id)))
const protections = async (serverIds: number[]) => {
const rows = await deps.query(`SELECT object_ref,xmin::text AS revision FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) OR (object_ref->>'kind' IN ('netflow-settings','netflow-host') AND object_ref->>'id'='1') ORDER BY id LIMIT 1001`, [serverIds.map(String)])
if (rows.length > 1000) throw conflict()
return { proof: await deps.proof(rows), objects: rows.map(row => row.object_ref as ChangeEvidence["objects"][number]) }
}
const authorize = async (binding: Binding, signal?: AbortSignal, external: () => Promise<unknown> = async () => {}) => {
signal?.throwIfAborted(); await external()
const current = await deps.connection(binding.principal)
if (Date.parse(binding.principal.credentialExpiresAt) <= Date.now() || current.owner_id !== binding.ownerId || current.policy.status !== "active" || current.policy.mode === "observer" || Date.parse(current.policy.expiresAt) <= Date.now() || current.owner_valid_until.getTime() <= Date.now() || !current.policy.scope.allowGlobal || !current.policy.scope.operationIds.includes(operation) || binding.serverIds.some(id => !current.policy.scope.serverIds.includes(id)) || !hasPermission(current.owner_permissions, "mm:traffic:write") || !hasPermission(current.policy.permissions, "mm:traffic:write")) throw new McpError("PERMISSION_DENIED", "Нет актуального допуска генерации ключей NetFlow", 403)
if (current.generation !== binding.generation || fingerprint(current.policy) !== binding.policyProof || fingerprint(current.owner_permissions) !== binding.ownerProof || fingerprint(await catalog()) !== fingerprint(binding.serverIds) || (await protections(binding.serverIds)).proof !== binding.protectionProof) throw conflict()
signal?.throwIfAborted()
}
const request = async (raw: Record<string, unknown>) => {
const input = inputSchema.parse(raw), binding = bindingSchema.parse(JSON.parse(await deps.open(input.bindingCiphertext)))
if (binding.receiptId !== input.receiptId || await deps.proof(binding.before) !== binding.beforeProof) throw conflict()
return binding
}
const current = async (binding: Binding, external?: () => Promise<unknown>) => {
const check = () => authorize(binding, undefined, external)
await check(); const configuration = await deps.read(check); await check()
return { configuration, proof: await deps.proof(configuration) }
}
const prepare = async (raw: Record<string, unknown>, context: ActionContext): Promise<ChangeEvidence> => {
flowHostKeysActionSchema.parse(raw); context.signal?.throwIfAborted()
const serverIds = await catalog(), protection = await protections(serverIds)
const binding: Binding = { operation, version: 1, receiptId: randomUUID(), principal: context.principal,
ownerId: context.connection.owner_id, ownerProof: fingerprint(context.connection.owner_permissions), policyProof: fingerprint(context.connection.policy), generation: context.connection.generation,
serverIds, protectionProof: protection.proof, beforeProof: "0".repeat(64), before: {} }
const check = () => authorize(binding, context.signal)
await check(); binding.before = await deps.read(check); binding.beforeProof = await deps.proof(binding.before); await check()
return { target: { scope: "global" }, snapshots: [], requiresHuman: true, mutationCost: 1,
objects: [{ kind: "netflow-settings", id: "1" }, { kind: "netflow-host", id: "1" }, ...serverIds.map(serverId => ({ kind: "server", id: String(serverId), serverId })), ...protection.objects],
before: { proof: binding.beforeProof }, steps: [{ kind: "service", service: operation, input: { receiptId: binding.receiptId, bindingCiphertext: await deps.seal(JSON.stringify(bindingSchema.parse(binding))) } }],
warnings: ["Создаётся ключевая пара хоста NetFlow только при отсутствии полной пары. Уже сохранённая полная пара не заменяется. При неполной паре заменяются оба поля, как в REST.", "Изменение глобальное и требует текущего допуска ко всем серверам. Модель получает публичный ключ и квитанцию; приватный ключ не экспортируется.", "Collector и host-agent не запускаются и не перезапускаются; RouterOS, SSH и файлы не затрагиваются. Квитанция не подтверждает готовность туннеля.", "При потере результата повтор этой попытки запрещён. Совпадение конфигурации без сохранённого VERIFIED не подтверждает завершение операции."] }
}
const matches = (value: unknown, binding: Binding, proof: string) => {
const receipt = receiptSchema.safeParse(value)
const completeBefore = Boolean(binding.before.hostPublicKey && binding.before.hostPrivateKey)
return receipt.success && receipt.data.receiptId === binding.receiptId && receipt.data.afterProof === proof && receipt.data.created === !completeBefore && (!completeBefore || receipt.data.publicKey === binding.before.hostPublicKey)
}
const service: McpServiceHandler = {
current: async input => ({ proof: (await current(await request(input))).proof }),
execute: async input => {
const binding = await request(input), before = await current(binding)
if (before.proof !== binding.beforeProof) throw conflict()
const check = async () => { await deps.verifiedAccess(); await authorize(binding) }
const expected: FlowSettingsPlan = { command: { kind: "ensure-keys" }, before: before.configuration }
const receipt = await deps.generate({ receiptId: binding.receiptId, expected, authorize: check })
const after = await current(binding)
if (!matches(receipt, binding, after.proof) || receipt.publicKey !== after.configuration.hostPublicKey) throw new McpError("MUTATION_OUTCOME_UNKNOWN", "Результат создания ключей не подтверждён; повтор запрещён", 503)
return receipt
},
verify: async (input, result) => { const binding = await request(input); return matches(result, binding, (await current(binding)).proof) },
reconcile: async (input, external) => {
const binding = await request(input), latest = await current(binding, external)
const rows = await deps.query("SELECT o.connection_id,s.state,s.intended_state,s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id WHERE o.connection_id=$1 AND s.intended_state->'input'->>'receiptId'=$2 LIMIT 2", [binding.principal.connectionId, binding.receiptId])
const row = rows[0]
const verified = rows.length === 1 && row.connection_id === binding.principal.connectionId && row.state === "verified" && fingerprint(row.intended_state) === fingerprint(redact({ kind: "service", service: operation, input })) && matches(row.result, binding, latest.proof)
if ((await current(binding, external)).proof !== latest.proof) throw conflict()
return { matchesDesiredState: verified, matchesOriginalState: latest.proof === binding.beforeProof }
},
}
return { prepare, service }
}
export function registerFlowHostKeysActions(register: typeof add) {
const adapter = createFlowHostKeysAdapter()
registerMcpService(operation, adapter.service)
register(operation, "mm_netflow_host_keys_generate_plan", "Создать отсутствующие ключи хоста NetFlow", "traffic", "mm:traffic:write", "write", "global", flowHostKeysActionSchema, { prepare: adapter.prepare })
}
@@ -0,0 +1,150 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { setTimeout as delay } from "node:timers/promises"
import type { Pool } from "pg"
const endpoint = new URL(process.env.DATABASE_URL ?? "postgresql://invalid")
assert.equal(process.env.MMAPP_OWN_FLOW_HOST_KEYS, "true")
assert.equal(endpoint.hostname, "127.0.0.1"); assert.equal(endpoint.pathname, "/postgres")
assert.match(process.env.MCP_STATE_DIR ?? "", /mm-mcp-test-/)
process.env.AUTH_REQUIRED = "true"; process.env.AUTH_ISSUER = "https://auth.test.local"
const { pool, closePool } = await import("../../db/index.js")
const { applySqlMigrations } = await import("../../db/migrate.js")
const { createFlowSettingsStore } = await import("../../services/traffic-flow-settings-store.js")
const { generateFlowHostKeys, flowSettingsPrivateProof } = await import("../../services/traffic-flow-host-keys-service.js")
const { invalidateTrafficFlowSettingsCache } = await import("../../services/traffic-flow-settings.js")
const { withFlowConfigurationLock } = await import("../../services/traffic-flow-host-agent.js")
const { withApplicationMutationLock } = await import("../../services/config-mutation-lock.js")
const { getFlowListenerState, getFlowWorkerHealth } = await import("../../services/traffic-flow-ingest.js")
const { buildApp } = await import("../../index.js")
const { env } = await import("../../config.js")
const { createMcpConnection, authenticateMcpToken, updateMcpSettings, transaction } = await import("./store.js")
const { invokeMcpTool, approvePlan, applyPlan } = await import("./runtime.js")
const { decryptSecret } = await import("./security.js")
const actor = { id: "flow-keys-fixture-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:traffic:admin"], expiresAt: Date.now() + 3600000 }
let app: Awaited<ReturnType<typeof buildApp>> | undefined
let network = 0
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => { network++; throw Error("Isolated key generation prohibits network") }) as typeof fetch
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await pool.query("ALTER TABLE traffic_flow_settings ADD COLUMN fixture_future jsonb NOT NULL DEFAULT '{\"opaque\":true}'::jsonb")
const store = createFlowSettingsStore(pool, async () => {}, invalidateTrafficFlowSettingsCache)
{
const before = await store.read(), receiptId = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(receiptId, "configuration", proof)
await assert.rejects(store.claim(receiptId, "configuration", proof))
await pool.query("UPDATE traffic_flow_settings SET packets_received=111,last_error='telemetry',updated_at=now() WHERE id=1")
assert.deepEqual(await store.read(), before)
const desired = { ...before, hostPublicKey: "fixture-public", hostPrivateKey: "fixture-private" }
assert.equal(await store.compareAndSet(receiptId, proof, before, desired), true)
assert.deepEqual(await store.read(), desired)
assert.equal((await pool.query("SELECT packets_received FROM traffic_flow_settings WHERE id=1")).rows[0].packets_received, 111)
await assert.rejects(store.compareAndSet(receiptId, proof, before, desired))
}
{
const before = await store.read(), id = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(id, "configuration", proof)
await pool.query("UPDATE traffic_flow_settings SET fixture_future='{\"opaque\":false}' WHERE id=1")
assert.equal(await store.compareAndSet(id, proof, before, { ...before, hostPublicKey: "denied" }), false)
assert.notEqual((await store.read()).hostPublicKey, "denied")
}
{
const before = await store.read(), id = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(id, "configuration", proof)
let commitAttempts = 0, quarantined = false
const lost = createFlowSettingsStore({ query: pool.query.bind(pool), connect: async () => {
const client = await pool.connect()
return { query: async (sql: string, values?: unknown[]) => {
const result = await client.query(sql, values)
if (sql === "COMMIT") { commitAttempts++; throw Error("synthetic lost commit ACK") }
return result
}, release: (unsafe?: boolean) => { quarantined = Boolean(unsafe); client.release(unsafe) } }
} } as unknown as Pick<Pool, "connect" | "query">, async () => {}, invalidateTrafficFlowSettingsCache)
await assert.rejects(lost.compareAndSet(id, proof, before, { ...before, hostPublicKey: "lost-ack-public" }))
assert.equal(commitAttempts, 1); assert.equal(quarantined, true)
assert.equal((await store.read()).hostPublicKey, "lost-ack-public")
await assert.rejects(store.claim(id, "configuration", proof))
}
{
const before = await store.read(), proof = await flowSettingsPrivateProof(before), attempts = [randomUUID(), randomUUID()]
await Promise.all(attempts.map(id => store.claim(id, "configuration", proof)))
const raced = await Promise.all(attempts.map((id, index) => store.compareAndSet(id, proof, before, { ...before, publicEndpoint: `fixture-${index}` })))
assert.equal(raced.filter(Boolean).length, 1, "full CAS admits only one concurrent different update")
}
{
const before = await store.read(), id = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(id, "configuration", proof)
let revoked = false
const denied = createFlowSettingsStore({ query: pool.query.bind(pool), connect: async () => {
const client = await pool.connect()
return { query: async (sql: string, values?: unknown[]) => {
const result = await client.query(sql, values)
if (sql.startsWith("UPDATE traffic_flow_settings")) revoked = true
return result
}, release: (unsafe?: boolean) => client.release(unsafe) }
} } as unknown as Pick<Pool, "connect" | "query">, async () => { if (revoked) throw Error("synthetic revoke after UPDATE") }, invalidateTrafficFlowSettingsCache)
await assert.rejects(denied.compareAndSet(id, proof, before, { ...before, hostPublicKey: "must-rollback" }))
assert.deepEqual(await store.read(), before)
assert.equal((await pool.query("SELECT state FROM flow_settings_intents WHERE receipt_id=$1", [id])).rows[0].state, "claimed")
await assert.rejects(store.claim(id, "configuration", proof))
}
const listener = getFlowListenerState()
for (const [publicKey, privateKey] of [["", ""], ["", "synthetic-existing-private"], ["synthetic-existing-public", ""], ["", ""]]) {
await pool.query("UPDATE traffic_flow_settings SET host_public_key=$1,host_private_key=$2 WHERE id=1", [publicKey, privateKey]); invalidateTrafficFlowSettingsCache()
const before = await store.read(), first = await generateFlowHostKeys()
assert.equal(first.created, true); assert.match(first.publicKey, /^[A-Za-z0-9+/]{43}=$/)
const after = await store.read()
assert.deepEqual({ ...after, hostPublicKey: before.hostPublicKey, hostPrivateKey: before.hostPrivateKey }, before)
const second = await withApplicationMutationLock(() => withFlowConfigurationLock(() => generateFlowHostKeys()))
assert.equal(second.created, false); assert.equal(second.publicKey, first.publicKey)
assert.deepEqual(await store.read(), after)
assert.ok(!JSON.stringify(first).includes(after.hostPrivateKey))
}
const vrfTickets = await (await import("../../services/vrf-production-ticket-store-sql.test.js")).runVrfProductionTicketSqlAcceptance({ sql: pool, transaction, claimConnections: { connect: () => pool.connect() } })
assert.equal(vrfTickets.passed, true)
console.log(`VRF production tickets SQL: ${vrfTickets.cases} actual one-use/owner/source/expiry/concurrency/unknown claims; no native execution grant`)
await updateMcpSettings({ enabled: true }, actor)
app = await buildApp({ startScheduler: false }); await app.ready()
const access = await createMcpConnection({ name: "host keys acceptance", mode: "autonomous", permissions: ["mm:traffic:write"], expiresAt: new Date(Date.now() + 600000).toISOString(), scope: { allowGlobal: true, serverIds: [], operationIds: ["traffic.flow.settings.generate-keys"] }, limits: { callsPerMinute: 240, mutationsPerHour: 1000 } }, actor)
const principal = await authenticateMcpToken(access.token)
const wait = async (id: string) => {
for (const deadline = Date.now() + 45000; Date.now() < deadline;) {
const row = (await pool.query("SELECT state,verification,error_code FROM mcp_operations WHERE id=$1", [id])).rows[0]
if (!["pending", "running"].includes(row.state)) return row
await delay(20)
}
throw Error("Operation still live, do not replay")
}
await pool.query("UPDATE traffic_flow_settings SET host_public_key='',host_private_key='' WHERE id=1"); invalidateTrafficFlowSettingsCache()
const planned = await invokeMcpTool(principal, "mm_netflow_host_keys_generate_plan", {}) as { planId: string; state: string; changes: unknown }
assert.equal(planned.state, "awaiting-approval")
const evidence = JSON.parse(await decryptSecret((await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [planned.planId])).rows[0].evidence.sealed))
assert.equal(evidence.requiresHuman, true); assert.equal(evidence.mutationCost, 1)
await assert.rejects(applyPlan(principal, planned.planId, randomUUID()))
await approvePlan(planned.planId, actor, true)
const operation = await applyPlan(principal, planned.planId, randomUUID()), finished = await wait(operation.id)
assert.equal(finished.state, "applied", JSON.stringify(finished)); assert.equal(finished.verification, "passed")
const result = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows[0].result
const current = await store.read()
assert.equal(result.publicKey, current.hostPublicKey); assert.equal(result.created, true)
assert.ok(!JSON.stringify({ planned, result }).includes(current.hostPrivateKey))
assert.equal((await pool.query("SELECT count(*)::int AS n FROM flow_settings_intents WHERE receipt_id=$1", [result.receiptId])).rows[0].n, 1)
await assert.rejects(generateFlowHostKeys({ receiptId: result.receiptId }))
const jwt = app.jwt.sign({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }, { iss: env.authIssuer, expiresIn: "10m" })
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/settings/generate-keys", headers: { authorization: "Bearer " + jwt } })
assert.equal(response.statusCode, 200, response.body)
assert.equal(response.json().created, false); assert.equal(response.json().publicKey, result.publicKey)
assert.ok(!response.body.includes(current.hostPrivateKey))
assert.equal((await app.inject({ method: "POST", url: "/api/traffic/flow/settings/generate-keys" })).statusCode, 401)
const protectedId = randomUUID()
await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2,$3)", [protectedId, { kind: "netflow-settings", id: "1", protection: "deny" }, actor.id])
const denied = await app.inject({ method: "POST", url: "/api/traffic/flow/settings/generate-keys", headers: { authorization: "Bearer " + jwt } })
assert.equal(denied.statusCode, 409, denied.body)
await assert.rejects(invokeMcpTool(principal, "mm_netflow_host_keys_generate_plan", {}))
await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protectedId])
assert.equal(network, 0); assert.equal(getFlowWorkerHealth().alive, false); assert.deepEqual(getFlowListenerState(), listener)
await (await import("./flow-settings-acceptance.js")).acceptMcpFlowSettingsUpdate(actor, app)
console.log("NetFlow keys SQL/HTTP: complete config CAS incl future columns; telemetry separation; unique claims; lost COMMIT no replay; real generation, reentrant shared locks; mandatory MCP human and private binding; JWT REST parity/protection; zero collector/network")
} finally { globalThis.fetch = originalFetch; await app?.close(); await closePool() }
@@ -0,0 +1,74 @@
import { assertBoundedFlowSource as assertTrafficHistorySource } from "./flow-source-scope.js"
import { withCurrentFlowReadQuality } from "./flow-read-quality.js"
import { setTimeout as delay } from "node:timers/promises"
import { z } from "zod"
import { flowAnalyticsDtoSchema } from "@mmapp/contracts/traffic-flow"
import { safeBuildLiveFlowSample, LIVE_ANALYTICS_MINUTES } from "../../services/traffic-flow-analytics.js"
import { FLOW_LIVE_TICK_MS, tryAcquireFlowLiveSlot, releaseFlowLiveSlot } from "../../services/traffic-flow-live-slots.js"
import { getBoundedFlowServerRowById as getServerRowById } from "../../services/traffic-flow-server-source.js"
import { assertTrafficHistoryAccess } from "./traffic-history-actions.js"
import { McpError } from "./security.js"
import type { add } from "./actions.js"
export function registerFlowLiveActions(register: typeof add) {
register("traffic.flow.live.read", "mm_netflow_live_observe", "NetFlow: ограниченное наблюдение live-потока", "traffic", "mm:traffic:read", "read", "servers", z.object({
serverId: z.number().int().positive().safe(), userId: z.string().trim().min(1).max(100).optional(),
interface: z.string().trim().min(1).max(200).refine(value => !/[\u0000-\u001f\u007f]/.test(value) && value !== "__all__").optional(),
dedup: z.boolean().default(true), excludeMesh: z.boolean().default(true), excludeOverlay: z.boolean().default(true),
samples: z.number().int().min(1).max(3).default(1),
offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(100).default(50),
}).strict(), {
read: async (input, context) => {
await assertTrafficHistoryAccess(context)
const serverId = Number(input.serverId)
// Keep this check local too: direct action invocations must not read a foreign source.
if (!context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SCOPE_DENIED", "Сервер вне разрешённой области", 403)
const server = await getServerRowById(serverId)
if (!server) throw new McpError("NOT_FOUND", "Сервер не найден", 404)
const authorize = () => assertTrafficHistorySource(context, server)
await authorize()
if (!tryAcquireFlowLiveSlot()) throw new McpError("RATE_LIMITED", "Слишком много live-подписок", 429)
const readStartedAt = new Date().toISOString()
try {
const events = []
for (let index = 0; index < Number(input.samples); index++) {
if (index) await delay(FLOW_LIVE_TICK_MS, undefined, { signal: context.signal })
await authorize()
const sampleStartedAt = new Date().toISOString()
const event = await safeBuildLiveFlowSample({ serverId, userId: input.userId as string | undefined, iface: input.interface as string | undefined,
dedup: input.dedup === true, excludeMesh: input.excludeMesh === true, excludeOverlay: input.excludeOverlay === true },
{ serverIds: context.connection.policy.scope.serverIds, maxRows: 20000, authorize })
await authorize()
// The REST formatter includes raw source error messages. Never publish these over MCP.
if (event.event !== "sample") throw new McpError("STATE_UNAVAILABLE", "Live NetFlow недоступен, источник изменился или превышает предел 20000 строк", 409)
const dto = flowAnalyticsDtoSchema.parse(event.data)
const data: Record<string, unknown> = { ...dto }
const pages: Record<string, { total: number; nextOffset: number | null }> = {}
const offset = Number(input.offset), limit = Number(input.limit)
for (const [key, value] of Object.entries(dto)) if (Array.isArray(value) && key !== "rxSeries" && key !== "txSeries") {
data[key] = value.slice(offset, offset + limit)
pages[key] = { total: value.length, nextOffset: offset + limit < value.length ? offset + limit : null }
}
events.push({ event: "sample" as const, sampleStartedAt, observedAt: new Date().toISOString(), data, pages })
await authorize()
}
const output = { serverId, userId: input.userId ?? null, interface: input.interface ?? null, events, readStartedAt, observedAt: new Date().toISOString(),
observationComplete: true, streamContinues: false, sampleIntervalMs: FLOW_LIVE_TICK_MS, windowMinutes: LIVE_ANALYTICS_MINUTES,
source: "REST live sample engine: current ring estimates and rolling flow_buckets plus pending; uncached scoped reads",
rowLimit: 20000, incomplete: true, coverageVerified: false, forwardingVerified: false, rowSumIsNetworkTotal: false,
historicalOwnershipVerified: false, currentTrafficVerified: false,
rateSemantics: "REST live rates: current server/interface ring; retained-window fallback; user filter does not attribute ring rates",
liveMeaning: "events[].data.live means listener bound; it does not prove exporter freshness or forwarding",
attribution: "current named whole-interface bindings; users with hidden bindings excluded",
collectorStarted: false, backgroundEnrichmentStarted: false }
if (Buffer.byteLength(JSON.stringify(output)) > 1048576) throw new McpError("STATE_UNAVAILABLE", "Ответ live превышает 1 МиБ; уменьшите страницу или число samples", 409)
await authorize()
return withCurrentFlowReadQuality(output,context)
} catch (error) {
await authorize()
if (error instanceof McpError) throw error
throw new McpError("STATE_UNAVAILABLE", "Live NetFlow недоступен или чтение отменено", 409)
} finally { releaseFlowLiveSlot() }
},
})
}
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff. Show more