feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
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 токена."] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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) }
|
||||
} })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }))
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")))
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 с прежним ключом требует нового секрета для назначения или явного удаления ключа."],
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
} })
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user