diff --git a/app/globals.css b/app/globals.css index ae204b9d..31af6717 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,16 @@ -@import "tailwindcss"; +@import "tailwindcss" source(none); @import "tw-animate-css"; @import "shadcn/tailwind.css"; +/* Scan application sources only; local test archives and generated bundles + must not become utility classes in the production stylesheet. */ +@source "./"; +@source "../components"; +@source "../entities"; +@source "../hooks"; +@source "../lib"; +@source "../shared"; + /* SVG focus follows the object shape, never the text/foreignObject bounding box. */ .network-map-focusable { outline: none; diff --git a/backend/drizzle/0056_mcp_manual_inverse_recovery_v8.sql b/backend/drizzle/0056_mcp_manual_inverse_recovery_v8.sql new file mode 100644 index 00000000..9c84baa0 --- /dev/null +++ b/backend/drizzle/0056_mcp_manual_inverse_recovery_v8.sql @@ -0,0 +1,43 @@ +-- Keep the live charge and intent fences in sync with recovery action v8. +-- Existing authenticated v6/v7 history remains unchanged. +BEGIN; +CREATE OR REPLACE FUNCTION public.mcp_routing_manual_inverse_charge_source() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS( + SELECT 1 FROM public.mcp_operations o + JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id + JOIN public.mcp_routing_profiles r ON r.id=NEW.profile_id AND r.connection_id=o.connection_id + JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id + JOIN public.mcp_plans sp ON sp.id=source.plan_id AND sp.connection_id=source.connection_id + JOIN public.mcp_connections c ON c.id=o.connection_id + WHERE o.id=NEW.recovery_operation_id AND p.id=NEW.recovery_plan_id + AND o.connection_id=NEW.connection_id AND c.owner_id=NEW.owner_id + AND r.version=NEW.profile_version AND o.state='pending' AND p.state='executing' + AND p.operation_id='operations.recover' AND p.operation_version=8 + AND sp.id=NEW.source_plan_id AND sp.operation_id='routing.community.switch' AND sp.operation_version=7 + AND source.state IN('applied','partial') + ) THEN RAISE EXCEPTION 'manual inverse charge differs from pending admission'; END IF; + RETURN NEW; +END $$; +CREATE OR REPLACE FUNCTION public.mcp_scoped_recovery_manual_version() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.mode='manual' AND NOT pg_try_advisory_xact_lock(hashtextextended('mcp-runner-admission',0)) + THEN RAISE EXCEPTION 'manual intent admission snapshot busy'; END IF; + IF NEW.mode='manual' AND NOT EXISTS( + SELECT 1 FROM public.mcp_operations o + JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id + JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0 + JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id + WHERE o.id=NEW.recovery_operation_id AND o.state='running' + AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=8 + AND NEW.source_step_index=0 AND s.state='intent' + AND s.intended_state->>'kind'='service' + AND s.intended_state->>'service'='routing.community.scoped.recover' + AND s.intended_state->'input'->>'mode'='manual' + AND s.intended_state->'input'->>'sourceOperationId'=NEW.source_operation_id::text + AND s.intended_state->'input'->>'receiptId'=NEW.recovery_receipt_id::text + ) THEN RAISE EXCEPTION 'manual inverse requires current exact running recovery'; END IF; + RETURN NEW; +END $$; +INSERT INTO public.schema_migrations(id) VALUES('0056_mcp_manual_inverse_recovery_v8') ON CONFLICT(id) DO NOTHING; +COMMIT; diff --git a/backend/mcp-coverage.json b/backend/mcp-coverage.json index a3c5388f..df94ca49 100644 --- a/backend/mcp-coverage.json +++ b/backend/mcp-coverage.json @@ -597,6 +597,13 @@ "operations": [], "reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет." }, + { + "method": "GET", + "path": "/api/mcp/devices/:serverId/bridge-vlan/configuration", + "status": "mediated", + "operations": ["network.bridge-vlan.read"], + "reason": "Чтение связанной конфигурации bridge/VLAN через общую типизированную модель и проверку доступа MCP." + }, { "method": "GET", "path": "/api/mcp/drift", @@ -1611,6 +1618,13 @@ "operations": [], "reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет." }, + { + "method": "POST", + "path": "/api/mcp/devices/:serverId/bridge-vlan/configuration/plan", + "status": "mediated", + "operations": ["network.bridge-vlan.configure"], + "reason": "Подготовка связанного плана bridge/VLAN через тот же MCP-инструмент; выполнение требует человеческого подтверждения." + }, { "method": "POST", "path": "/api/mcp/management-channels", diff --git a/backend/scripts/mcp-external-process/bridge-vlan-client.mjs b/backend/scripts/mcp-external-process/bridge-vlan-client.mjs new file mode 100644 index 00000000..50046272 --- /dev/null +++ b/backend/scripts/mcp-external-process/bridge-vlan-client.mjs @@ -0,0 +1,73 @@ +/** External SDK consumer. No application/store/runtime/database imports. */ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' + +const endpoint = new URL(process.env.OWN_MCP_ENDPOINT) +assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true') +assert.equal(endpoint.hostname, '127.0.0.1') +assert.notEqual(process.pid, Number(process.env.OWN_SERVER_PID)) +const client = new Client({ name: 'owned-linked-bridge-vlan-sdk', version: '1.0.0' }) +const boundedFetch = async (input, init) => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url) + assert.equal(url.origin, endpoint.origin) + return fetch(input, { ...init, redirect: 'manual', signal: AbortSignal.timeout(10000) }) +} +const payload = result => { + const text = result.content.find(item => item.type === 'text')?.text + assert(text && Buffer.byteLength(text) < 131072) + return JSON.parse(text) +} +const call = async (name, args) => { + const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 10000 }) + assert(!result.isError, JSON.stringify(payload(result))) + return payload(result) +} +try { + await client.connect(new StreamableHTTPClientTransport(endpoint, { + requestInit: { headers: { authorization: 'Bearer ' + process.env.OWN_MCP_TOKEN } }, fetch: boundedFetch, + }), { timeout: 10000 }) + const tools = (await client.listTools({}, { timeout: 10000 })).tools + const read = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_get') + const configure = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_plan') + assert(read && configure) + assert.match(read.description, /Полная конфигурация bridge VLAN/) + assert.match(read.description, /Возвращает данные только из разрешённой области/) + assert.match(configure.description, /Связанный план VLAN membership, PVID и bridge filtering/) + assert.match(configure.description, /Подготавливает неизменяемый план/) + assert.equal(read.annotations.readOnlyHint, true) + assert.equal(configure.annotations.readOnlyHint, false) + assert.deepEqual(read.inputSchema.required, ['serverId']) + assert.deepEqual(configure.inputSchema.required, ['serverId', 'bridgeId', 'desired']) + const desiredSchema = configure.inputSchema.properties.desired + for (const name of ['ports', 'vlans', 'removePortIds', 'removeVlanIds']) assert(desiredSchema.properties[name]) + const serverId = Number(process.env.OWN_ALLOWED_SERVER) + const state = (await call(read.name, { serverId })).state + assert.equal(state.bridges[0]['.id'], '*1') + assert(state.interfaces.some(item => item.resource === 'ethernet' && item.id === '*3' && item.name === 'ether3')) + assert.equal(state.vlans[0]['vlan-ids'], '10') + const cpu = { resource: 'bridge', id: '*1' }, access = { resource: 'ethernet', id: '*2' }, trunk = { resource: 'ethernet', id: '*3' } + const plan = await call(configure.name, { serverId, bridgeId: '*1', desired: { + filtering: true, pvid: 1, + ports: [{ id: '*10', interface: access, pvid: 10, ingressFiltering: true, frameTypes: 'admit-all', disabled: false }, + { interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: 'admit-only-vlan-tagged', disabled: false }], + vlans: [{ id: '*11', vlanIds: '10', tagged: [cpu], untagged: [access], disabled: false }, + { vlanIds: '20', tagged: [cpu, trunk], untagged: [], disabled: false }], removePortIds: [], removeVlanIds: [], + } }) + assert.equal(plan.state, 'awaiting-approval') + assert.equal(typeof plan.planId, 'string') + assert.equal(plan.changes.requiresHuman, true) + assert.deepEqual(plan.changes.steps.map(item => [item.step.resource, item.step.action]), + [['bridge_vlan', 'create'], ['bridge_port', 'create'], ['bridge', 'update']]) + const denied = await client.callTool({ name: 'mm_change_apply', arguments: { planId: plan.planId, idempotencyKey: randomUUID() } }, undefined, { timeout: 10000 }) + assert.equal(denied.isError, true) + const deniedPayload = payload(denied) + assert(JSON.stringify(deniedPayload).includes('APPROVAL_REQUIRED')) + const readback = (await call(read.name, { serverId })).state + assert.deepEqual(readback, state) + process.send({ kind: 'result', result: { externalSdkProcess: true, transport: 'streamable-http', clientPid: process.pid, + listedTools: [read, configure], state, plan, applyDenial: deniedPayload, unchangedReadback: true } }) +} finally { + await client.close() + process.disconnect?.() +} diff --git a/backend/scripts/mcp-external-process/bridge-vlan-fixture.ts b/backend/scripts/mcp-external-process/bridge-vlan-fixture.ts new file mode 100644 index 00000000..7e106061 --- /dev/null +++ b/backend/scripts/mcp-external-process/bridge-vlan-fixture.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { writeFile } from 'node:fs/promises' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { isAbsolute, resolve } from 'node:path' + +assert.equal(process.env.OWN_EXTERNAL_PROCESS_PG, 'true') +assert.equal(process.env.NODE_ENV, 'test') +assert(isAbsolute(process.env.OWN_EXTERNAL_SOURCE_ROOT!)) +assert(isAbsolute(process.env.OWN_EXTERNAL_RESULT_FILE!)) +const database = new URL(process.env.DATABASE_URL!) +assert.equal(database.hostname, '127.0.0.1'); assert.equal(database.username, 'mcp_test') +assert.equal(database.pathname, '/postgres'); assert.notEqual(database.port, '5432') +const source = (path: string) => pathToFileURL(resolve(process.env.OWN_EXTERNAL_SOURCE_ROOT!, 'backend/src', path)).href +const rows: Record[]> = { + '/interface/ethernet': [{ '.id': '*2', name: 'ether2', disabled: 'false' }, { '.id': '*3', name: 'ether3', disabled: 'false' }, { '.id': '*4', name: 'ether-mgmt', disabled: 'false' }], + '/interface/bridge': [{ '.id': '*1', name: 'br-users', pvid: '1', 'vlan-filtering': 'false', 'frame-types': 'admit-all', 'ingress-filtering': 'true', mvrp: 'false', disabled: 'false' }], + '/interface/bridge/port': [{ '.id': '*10', bridge: 'br-users', interface: 'ether2', pvid: '10', 'ingress-filtering': 'true', 'frame-types': 'admit-all', disabled: 'false' }], + '/interface/bridge/vlan': [{ '.id': '*11', bridge: 'br-users', 'vlan-ids': '10', tagged: 'br-users', untagged: 'ether2', disabled: 'false', dynamic: 'false' }], + '/interface/vlan': [], '/interface/gre': [], '/interface/wireguard': [], '/interface/vxlan': [], + '/ip/address': [{ '.id': '*20', interface: 'ether-mgmt', address: '127.0.0.1/8', disabled: 'false', dynamic: 'false' }], '/ipv6/address': [], +} +const requests: string[] = [] +let writeAttempts = 0 +const router = createServer((req, res) => { + res.setHeader('content-type', 'application/json') + const path = decodeURIComponent(new URL(req.url!, 'http://127.0.0.1').pathname.replace(/^\/rest/, '')) + requests.push(req.method + ' ' + path) + if (req.method !== 'GET') { writeAttempts++; res.writeHead(405); res.end('{}'); return } + if (req.headers.authorization !== 'Basic ' + Buffer.from('fixture:fixture-only').toString('base64')) { res.writeHead(401); res.end('{}'); return } + if (!Object.hasOwn(rows, path)) { res.writeHead(404); res.end('{}'); return } + res.end(JSON.stringify(rows[path])) +}) +await new Promise(done => router.listen(0, '127.0.0.1', done)) +const { pool, closePool } = await import(source('db/index.js')) +const { applySqlMigrations } = await import(source('db/migrate.js')) +const { mcpConnectionCreateSchema } = await import('@mmapp/contracts/mcp') +const { createMcpConnection, updateMcpSettings } = await import(source('modules/mcp/store.js')) +const { buildApp } = await import(source('index.js')) +const actor = { id: 'owned-bridge-vlan-sdk-owner', email: 'bridge-sdk@example.invalid', name: 'Owned bridge VLAN SDK owner', apps: ['mm'], permissions: ['mm:settings:admin', 'mm:mcp:admin', 'mm:network:admin', 'mm:servers:admin'], expiresAt: Date.now() + 3600000, issuedAt: Date.now() - 1000 } +let app: Awaited> | undefined +let child: ReturnType | undefined +let childExit: Promise | undefined +try { + await applySqlMigrations(pool) + const serverId = (await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('owned bridge VLAN SDK','127.0.0.1',$1,false,true,'fixture','fixture-only') RETURNING id", [(router.address() as { port: number }).port])).rows[0].id + await updateMcpSettings({ enabled: true }, actor) + app = await buildApp({ startScheduler: false, logger: false }) + await app.listen({ host: '127.0.0.1', port: 0 }) + const endpoint = `http://127.0.0.1:${(app.server.address() as { port: number }).port}/mcp` + const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: 'Owned bridge VLAN external SDK', mode: 'autonomous', + expiresAt: new Date(Date.now() + 1200000).toISOString(), permissions: ['mm:network:write'], + scope: { serverIds: [serverId], operationIds: ['network.bridge-vlan.read', 'network.bridge-vlan.configure', 'changes.apply'], allowGlobal: false }, + limits: { callsPerMinute: 120, mutationsPerHour: 100 } }), actor) + let result: unknown, stdout = '', stderr = '' + child = spawn(process.execPath, [fileURLToPath(new URL('./bridge-vlan-client.mjs', import.meta.url))], { cwd: process.cwd(), windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]!])), + OWN_EXTERNAL_CLIENT: 'true', OWN_MCP_ENDPOINT: endpoint, OWN_MCP_TOKEN: issued.token, OWN_SERVER_PID: String(process.pid), OWN_ALLOWED_SERVER: String(serverId) } }) + child.on('message', (message: { kind: string; result?: unknown }) => { if (message.kind === 'result') result = message.result }) + child.stdout?.on('data', chunk => { stdout += String(chunk); if (stdout.length > 1048576) child?.kill() }) + child.stderr?.on('data', chunk => { stderr += String(chunk); if (stderr.length > 1048576) child?.kill() }) + childExit = new Promise((done, reject) => { child!.once('exit', code => done(code ?? 1)); child!.once('error', reject) }) + const timeout = setTimeout(() => child?.kill(), 60000) + const code = await childExit; clearTimeout(timeout) + assert.equal(code, 0, stderr.slice(0, 3000)); assert(result) + assert.equal(writeAttempts, 0) + assert.equal((await pool.query('SELECT count(*)::int AS count FROM mcp_operations')).rows[0].count, 0) + const plans = (await pool.query('SELECT operation_id,state,approved_by FROM mcp_plans')).rows + assert.equal(plans.length, 1); assert.equal(plans[0].operation_id, 'network.bridge-vlan.configure') + assert.equal(plans[0].state, 'awaiting-approval'); assert.equal(plans[0].approved_by, null) + const audit = (await pool.query('SELECT tool,status,error_code FROM mcp_requests ORDER BY created_at')).rows + assert(audit.some(row => row.error_code === 'APPROVAL_REQUIRED')) + const serialized = JSON.stringify({ result, stdout, stderr, audit }) + assert(!serialized.includes(issued.token)); assert(!serialized.includes('fixture-only')) + const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid, + connectionMode: 'autonomous', plans, audit, nativeReadRequests: requests, nativeWriteAttempts: writeAttempts, + appliedOperations: 0, syntheticRouterOnly: true, productionDatabaseTouched: false, portalContacted: false } + await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2)) + console.log('Owned bridge VLAN SDK passed:', JSON.stringify({ listedTools: 2, planState: plans[0].state, nativeReadRequests: requests.length, nativeWriteAttempts: writeAttempts })) +} finally { + if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await childExit } + try { await app?.close() } finally { try { await closePool() } finally { router.closeIdleConnections(); await new Promise((done, reject) => router.close(error => error ? reject(error) : done())) } } +} diff --git a/backend/scripts/mcp-external-process/run-owned.mjs b/backend/scripts/mcp-external-process/run-owned.mjs index c41616ba..75c122a7 100644 --- a/backend/scripts/mcp-external-process/run-owned.mjs +++ b/backend/scripts/mcp-external-process/run-owned.mjs @@ -6,10 +6,14 @@ import { randomBytes, createHash } from 'node:crypto' import { createServer } from 'node:net' import { spawn } from 'node:child_process' const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url))) -const output = resolve(sourceRoot, 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true }) +const scenario = process.argv[3] ?? 'acceptance' +if (!['acceptance', 'bridge-vlan'].includes(scenario)) throw Error('Unsupported owned external scenario') +const output = resolve(sourceRoot, scenario === 'bridge-vlan' ? 'tmp/mcp-bridge-vlan-sdk-20261105' : 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true }) const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts', 'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts', 'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json'] +if (scenario === 'bridge-vlan') paths.push('backend/scripts/mcp-external-process/bridge-vlan-client.mjs', 'backend/scripts/mcp-external-process/bridge-vlan-fixture.ts', + 'backend/src/modules/mcp/bridge-vlan-actions.ts', 'backend/src/services/bridge-vlan-configuration.ts', 'packages/contracts/src/bridge-vlan.ts') async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result } const before = await pins(), directory = await mkdtemp(join(resolve(sourceRoot, 'tmp'), 'mm-mcp-external-')), state = join(directory, 'state'); await mkdir(state) const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve)) @@ -19,7 +23,7 @@ const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port try { await postgres.initialise(); await postgres.start() process.exitCode = await new Promise((done, reject) => { - const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('./server-fixture.ts', import.meta.url))], { cwd: directory, + const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL(scenario === 'bridge-vlan' ? './bridge-vlan-fixture.ts' : './server-fixture.ts', import.meta.url))], { cwd: directory, windowsHide: true, stdio: 'inherit', env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])), OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT, OWN_EXTERNAL_SOURCE_ROOT: sourceRoot, OWN_EXTERNAL_RESULT_FILE: join(output, 'result.json'), NODE_ENV: 'test', OWN_EXTERNAL_PROCESS_PG: 'true', DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'), MCP_STATE_DIR: state, AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'owned-external-synthetic-issuer-test-only', AUTH_ISSUER: 'https://synthetic-portal.invalid', AUTH_PORTAL_URL: 'https://synthetic-portal.invalid' } }) diff --git a/backend/scripts/mcp-external-process/server-fixture.ts b/backend/scripts/mcp-external-process/server-fixture.ts index 19bea7c4..51a5773e 100644 --- a/backend/scripts/mcp-external-process/server-fixture.ts +++ b/backend/scripts/mcp-external-process/server-fixture.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { createHash } from "node:crypto" import { spawn } from "node:child_process" import { createServer } from "node:net" import { fileURLToPath, pathToFileURL } from "node:url" @@ -49,6 +50,61 @@ try { await updateMcpSettings({ enabled: true }, actor) app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" }) const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) + const portalHeaders = { authorization: `Bearer ${jwt}`, "content-type": "application/json" } + assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_connections WHERE owner_id=$1", [actor.id])).rows[0].count, 0) + const callback = "http://localhost:45678/callback?registered=1" + const registration = await fetch(origin + "/api/mcp/oauth/clients", { method: "POST", headers: portalHeaders, + body: JSON.stringify({ name: "Owned denial fixture", redirectUris: [callback] }) }) + assert.equal(registration.status, 200) + const { clientId } = await registration.json() as { clientId: string } + const consent = (payload: Record, headers = portalHeaders) => fetch(origin + "/api/mcp/oauth/consent", { method: "POST", headers, body: JSON.stringify(payload) }) + const authorize = async (state: string) => { + const parameters = new URLSearchParams({ client_id: clientId, redirect_uri: callback, response_type: "code", resource: endpoint, + code_challenge_method: "S256", code_challenge: createHash("sha256").update("v".repeat(64)).digest("base64url"), state }) + const response = await fetch(`${origin}/oauth/authorize?${parameters}`, { redirect: "manual" }) + assert.equal(response.status, 302) + const requestId = new URL(response.headers.get("location")!).searchParams.get("oauthRequest") + assert.ok(requestId) + return requestId + } + const requestId = await authorize("deny-without-connection") + assert.equal((await consent({ requestId, approve: false }, { ...portalHeaders, authorization: "" })).status, 401) + assert.equal((await consent({ requestId, approve: true })).status, 400) + const refusals = await Promise.all([consent({ requestId, approve: false }), consent({ requestId, approve: false })]) + assert.deepEqual(refusals.map(response => response.status).sort(), [200, 404]) + const { redirectUrl } = await refusals.find(response => response.status === 200)!.json() as { redirectUrl: string } + const redirect = new URL(redirectUrl) + const expected = new URL(callback) + expected.searchParams.set("state", "deny-without-connection") + expected.searchParams.set("iss", origin) + expected.searchParams.set("error", "access_denied") + assert.equal(redirect.href, expected.href) + const otherOwner = { ...actor, id: "owned-external-process-other-owner" } + const unrelated = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Unrelated paused connection", mode: "observer", + expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"], + scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), otherOwner) + await setConnectionStatus(unrelated.connection.id, "paused", otherOwner) + const legacyRequestId = await authorize("legacy-denial") + const legacy = await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false }) + assert.equal(legacy.status, 200) + assert.equal((await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })).status, 404) + const expired = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Expired connection", mode: "observer", + expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"], + scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), actor) + await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1", [expired.connection.id, "2000-01-01T00:00:00Z"]) + const expiredRequestId = await authorize("expired-approval") + assert.equal((await consent({ requestId: expiredRequestId, connectionId: expired.connection.id, approve: true })).status, 403) + const expiredRequest = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE id=$1", [expiredRequestId])).rows[0] + assert.deepEqual(expiredRequest, { consumed_at: null, code_digest: null, connection_id: null }) + assert.equal((await consent({ requestId: expiredRequestId, approve: false })).status, 200) + const deniedRows = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE client_id=$1", [clientId])).rows + assert.equal(deniedRows.length, 3) + assert(deniedRows.every(row => row.consumed_at instanceof Date && row.code_digest === null && row.connection_id === null)) + assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_credentials WHERE client_id=$1", [clientId])).rows[0].count, 0) + const denialAudit = (await pool.query("SELECT actor_id,connection_id,payload FROM mcp_audit WHERE action='oauth.consent' AND payload->>'clientId'=$1", [clientId])).rows + assert.equal(denialAudit.length, 3) + assert(denialAudit.every(row => row.actor_id === actor.id && row.connection_id === null && row.payload.approved === false)) + console.log("OAuth consent: expired approval refused before code issuance, denial uses exact redirect and one-use request without credentials") const transcript: unknown[] = []; let failure: unknown, result: unknown child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"], env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])), @@ -90,7 +146,8 @@ try { const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows) for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) } const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid, - controlTransitions: transcript, auditCredentialLeakAbsent: true, nativeRouterCalls: 0, productionDatabaseTouched: false } + controlTransitions: transcript, auditCredentialLeakAbsent: true, denialWithoutConnection: true, + legacyDenialIgnoresPausedForeignConnection: true, nativeRouterCalls: 0, productionDatabaseTouched: false } await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2)) console.log("Owned external process SDK result:", JSON.stringify(final)) } finally { diff --git a/backend/scripts/vrf-production-profile.mjs b/backend/scripts/vrf-production-profile.mjs index 126fc1ff..cd6d74da 100644 --- a/backend/scripts/vrf-production-profile.mjs +++ b/backend/scripts/vrf-production-profile.mjs @@ -38,6 +38,7 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n if (Array.isArray(value.accepted)) { row.finalCaseCount = value.accepted.length; if (expectedSlice !== null && value.slice === expectedSlice) finalComponentPresent = true } if (['planned','queued','running','applied','failed','cancelled','unknown','expired','rejected','unconfirmed'].includes(value.operationState)) row.operationState = value.operationState for (const key of ['phase', 'elapsedMs', 'nativeReads', 'nativeWrites', 'roundTrips', 'lastSqlMs', 'totalSqlMs', 'maxSqlMs']) if (Number.isSafeInteger(value[key]) && value[key] >= 0) row[key] = value[key] + if (Number.isSafeInteger(value.wireguardPeerTelemetryTicks) && value.wireguardPeerTelemetryTicks > 0 && value.wireguardPeerTelemetryTicks <= 10000) row.wireguardPeerTelemetryTicks = value.wireguardPeerTelemetryTicks if (value.diagnosticWaitSql === true) row.diagnosticWaitSql = true if (value.diagnosticError === true) { row.diagnosticError = true; row.safeName = diagnosticNames.has(value.safeName) ? value.safeName : 'other'; row.safeCode = diagnosticCodes.has(value.safeCode) ? value.safeCode : 'other' } if (!Object.keys(row).length) { omittedLines++; continue } @@ -47,10 +48,10 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n } export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) { assert(result && typeof result === 'object') - assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias'].includes(result.slice)) + assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias','gre'].includes(result.slice)) assert(Number.isSafeInteger(result.code) && typeof result.childExitConfirmed === 'boolean') assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0) - assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : 120000) + assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : result.slice === 'disabled' ? 180000 : 120000) assert(result.failure === null || ['child-deadline','child-log-limit','child-signal','child-error','child-error-after-pid','child-exit-unconfirmed','missing-component-receipt'].includes(result.failure)) assert(result.accepted === (result.code === 0 && result.failure === null && result.childExitConfirmed)) return { ...result, capMs: childBudgetMs, diagnostics: projectVrfDiagnostics(log, privateValues, result.slice), scope: 'same-owned-PG-test-only', actualRouter: false, forwardingVerified: false } @@ -88,10 +89,14 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa const report = fileURLToPath(new URL('../../docs/mcp-chr-vrf-companion-native-v5.json', import.meta.url)) const loader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href const safe = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL'].filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]])) - for (const slice of ['baseline', 'physical', 'vlan', 'disabled', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) { + // Diagnose the disabled lifecycle first, then the synthetic GRE extension. + // Every original case remains required by the same full74 gate and caps. + for (const slice of ['disabled', 'gre', 'baseline', 'physical', 'vlan', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) { currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false }) - const childBudgetMs = slice === 'baseline' ? 240000 : 120000 + // Nine disabled lifecycle operations need their own aggregate child cap; + // measured seven operations alone consumed 105.5s under the original120s. + const childBudgetMs = slice === 'baseline' ? 240000 : slice === 'disabled' ? 180000 : 120000 const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd) assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd) assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink()) @@ -121,9 +126,10 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000) }) clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer) - const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4 } + const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4, gre: 6 } const component = log.trim().split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }).findLast(value => value.slice === slice && Array.isArray(value.accepted)) if (!component || component.accepted.length !== expectedCases[slice] || component.actualSql !== true || component.actualCrypto !== true || component.actualAuthPolicyApprovalRuntime !== true || component.fakeVerifier !== false || component.actualRouter !== false || component.forwardingVerified !== false) failure ??= 'missing-component-receipt' + if (slice === 'gre' && (!Number.isSafeInteger(component?.wireguardPeerTelemetryTicks) || component.wireguardPeerTelemetryTicks <= 6 || component.wireguardPeerTelemetryTicks > 10000)) failure ??= 'missing-component-receipt' const result = { slice, accepted: code === 0 && !failure && exited, caseCount: component?.accepted?.length ?? 0, code, failure, childPid: child.pid ?? null, childExitConfirmed: exited } lastSliceResult = result; currentChildExitConfirmed = exited const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret]) @@ -135,8 +141,8 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa console.log(JSON.stringify({ profile: 'test:vrf:production', ...result })) if (!result.accepted) break } - const receipt = { accepted: results.length === 10 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results } - await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 68, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath }) + const receipt = { accepted: results.length === 11 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results } + await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 74, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath }) await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2)) return receipt } catch { diff --git a/backend/scripts/vrf-production-profile.test.mjs b/backend/scripts/vrf-production-profile.test.mjs new file mode 100644 index 00000000..cfcd4bc4 --- /dev/null +++ b/backend/scripts/vrf-production-profile.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { vrfSliceDiagnostic } from './vrf-production-profile.mjs' + +test('only the nine-operation disabled slice receives its measured aggregate budget', () => { + for (const [slice, capMs] of [['disabled', 180000], ['gre', 120000], ['baseline', 240000]]) { + const result = { slice, accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true } + assert.equal(vrfSliceDiagnostic(result, '', capMs).capMs, capMs) + for (const otherCap of [120000, 180000, 240000].filter(value => value !== capMs)) assert.throws(() => vrfSliceDiagnostic(result, '', otherCap)) + } +}) + +test('GRE lifecycle diagnostic retains the six-case receipt with existing privacy bounds', () => { + const accepted = ['vrf-create', 'vrf-rename', 'vrf-disable', 'vrf-reenable', 'vrf-membership-transfer-a-to-b', 'vrf-delete-addresses-remain'] + const result = { slice: 'gre', accepted: true, caseCount: 6, code: 0, failure: null, childExitConfirmed: true } + const log = accepted.map((acceptedCase, index) => JSON.stringify({ slice: 'gre', acceptedCase, nativeWrites: 1, wireguardPeerTelemetryTicks: (index + 1) * 4, password: 'fixture-private-value' })) + log.push(JSON.stringify({ slice: 'gre', accepted, wireguardPeerTelemetryTicks: 24, rawShape: 'synthetic GRE lifecycle', password: 'fixture-private-value' })) + const diagnostic = vrfSliceDiagnostic(result, log.join('\n'), 120000, ['fixture-private-value']) + assert.deepEqual(diagnostic.diagnostics.acceptedCaseSequence, accepted) + assert.equal(diagnostic.diagnostics.finalComponentPresent, true) + assert.equal(diagnostic.diagnostics.rawLogRetained, false) + assert.deepEqual(diagnostic.diagnostics.records.map(row => row.wireguardPeerTelemetryTicks), [4, 8, 12, 16, 20, 24, 24]) + assert.equal(diagnostic.actualRouter, false) + assert.equal(diagnostic.forwardingVerified, false) + assert.equal(JSON.stringify(diagnostic).includes('fixture-private-value'), false) + assert.throws(() => vrfSliceDiagnostic(result, '', 240000)) +}) + +test('peer telemetry diagnostic rejects nonnumeric and out-of-budget values', () => { + const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'missing-component-receipt', childExitConfirmed: true } + for (const wireguardPeerTelemetryTicks of [0, -1, 10001, 1.5, 'private-raw-value', { secret: 'private-raw-value' }]) { + const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', wireguardPeerTelemetryTicks }), 120000) + assert.equal(diagnostic.diagnostics.records.some(row => Object.hasOwn(row, 'wireguardPeerTelemetryTicks')), false) + assert.equal(JSON.stringify(diagnostic).includes('private-raw-value'), false) + } +}) + +test('a partial GRE lifecycle is diagnostic evidence without a final component receipt', () => { + const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true } + const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', acceptedCase: 'vrf-create' }), 120000) + assert.equal(diagnostic.accepted, false) + assert.equal(diagnostic.diagnostics.finalComponentPresent, false) + assert.equal(diagnostic.diagnostics.lastAcceptedCase, 'vrf-create') + assert.equal(diagnostic.capMs, 120000) +}) diff --git a/backend/src/db/migration-manifest.test.ts b/backend/src/db/migration-manifest.test.ts index 6c456d4b..fdda585d 100644 --- a/backend/src/db/migration-manifest.test.ts +++ b/backend/src/db/migration-manifest.test.ts @@ -12,10 +12,10 @@ const reviewed = (source: string, extra: Partial = {}) => { const bytes = Buffer.from(source), file = "9000_fixture.sql" return () => analyzeMigration(file, bytes, { file, sha256: createHash("sha256").update(bytes).digest("hex"), transaction: "owner-transaction", selfMarker: false, opaqueBodies: 0, bodyReview: [], effects: ["Explicit inert fixture review"], ...extra }) } -await test("all 55 exact file pins and every top-level schema operation are inventoried", () => { - assert.equal(inventory.length, 55); assert.equal(MIGRATION_MANIFEST.length, 55) +await test("all 56 exact file pins and every top-level schema operation are inventoried", () => { + assert.equal(inventory.length, 56); assert.equal(MIGRATION_MANIFEST.length, 56) assert.equal(inventory.filter(file => file.transaction === "owner-transaction").length, 17) - assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 38) + assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 39) assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0)) let tables = new Set() for (const file of inventory) { @@ -32,8 +32,8 @@ await test("DO BEGIN and stored function BEGIN are never transaction boundaries" assert.equal(endpoint.topLevelTransactionControls.length, 2); assert.equal(endpoint.opaqueBodies.length, 2) assert.deepEqual(endpoint.opaqueBodies.map(body => body.kind), ["stored-function-body", "executed-do-body"]) }) -await test("0035 through 0055 have exact atomic file-owned terminal markers", () => { - assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement"]) +await test("0035 through 0056 have exact atomic file-owned terminal markers", () => { + assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement", "0056_mcp_manual_inverse_recovery_v8"]) for (const file of inventory.filter(file => file.selfMarker)) { assert.equal(file.selfMarker!.atomicWithAllFileEffects, true); assert.equal(file.selfMarker!.statement, file.statementCount - 2); assert.equal(file.currentOuterMarkerGap, false) } assert.equal(inventory.filter(file => file.currentOuterMarkerGap).length, 34) }) @@ -228,4 +228,20 @@ await test("0044 adds only a fixed manual inverse ledger and current-version int assert(!pin.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE")) }) +await test("0056 changes only live manual inverse recovery version guards", async () => { + const migration = inventory.find(file => file.id === "0056_mcp_manual_inverse_recovery_v8")! + assert.equal(migration.statementCount, 5) + assert.equal(migration.opaqueBodies.length, 2) + assert.equal(migration.selfMarker?.atomicWithAllFileEffects, true) + const oldSql = await readFile(new URL("../../drizzle/0044_mcp_routing_manual_inverse_charges.sql", import.meta.url), "utf8") + const newSql = await readFile(new URL("../../drizzle/0056_mcp_manual_inverse_recovery_v8.sql", import.meta.url), "utf8") + for (const name of ["mcp_routing_manual_inverse_charge_source", "mcp_scoped_recovery_manual_version"]) { + const definition = new RegExp(`CREATE(?: OR REPLACE)? FUNCTION public\\.${name}\\(\\)[\\s\\S]*?END \\$\\$;`) + const original = oldSql.match(definition)?.[0]?.replaceAll("\r\n", "\n") + assert.ok(original) + assert.equal(newSql.match(definition)?.[0]?.replaceAll("\r\n", "\n"), original.replace("CREATE FUNCTION", "CREATE OR REPLACE FUNCTION") + .replace("p.operation_id='operations.recover' AND p.operation_version=7", "p.operation_id='operations.recover' AND p.operation_version=8")) + } +}) + console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`) diff --git a/backend/src/db/migration-manifest.ts b/backend/src/db/migration-manifest.ts index 9d4e5d32..f8cef6ab 100644 --- a/backend/src/db/migration-manifest.ts +++ b/backend/src/db/migration-manifest.ts @@ -854,6 +854,20 @@ const classifiedMigrationPins: readonly MigrationReview[] = [ "effects": [ "Replaces the ordinary claim guard function without changing backup settings, metadata or entries during migration." ] + }, + { + "file": "0056_mcp_manual_inverse_recovery_v8.sql", + "sha256": "1dece1d9c5a9cf69e51336f35f868561dad9da6d21556ea31ef5b102634ed9ee", + "transaction": "self-managed-single-transaction", + "selfMarker": true, + "opaqueBodies": 2, + "bodyReview": [ + "The manual inverse charge-source guard requires recovery version 8 and preserves its pending operation, source version, owner and profile checks.", + "The manual inverse intent guard requires recovery version 8 and preserves its advisory lock and exact running service intent checks." + ], + "effects": [ + "Replaces the two live version guard functions and preserves existing charges, intents, and plans." + ] } ] diff --git a/backend/src/modules/mcp/actions.ts b/backend/src/modules/mcp/actions.ts index 0c89a00b..ef7b0faf 100644 --- a/backend/src/modules/mcp/actions.ts +++ b/backend/src/modules/mcp/actions.ts @@ -19,7 +19,7 @@ import { hasPermission } from "../../lib/permissions.js" import { assertOperationReplayScope, findOperationReplayTombstone, operationHistoryExpired } from "./operation-replay-tombstones.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 { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState, validateUserBindingCreate, UsersServiceError } 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" @@ -106,6 +106,7 @@ 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" +import { registerBridgeVlanActions, prepareGenericBridgeVlanChange } from "./bridge-vlan-actions.js" const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) const rowId = z.string().trim().min(1).max(200) @@ -129,6 +130,7 @@ registerRecursiveRecovery(add) registerNetworkHostActions(add) registerBgpFilterBindingActions(add) registerBgpCommunityPolicyActions(add) +registerBridgeVlanActions(add) registerTrafficHistoryActions(add) registerSidebarCountsActions(add) registerLogArtifactActions(add) @@ -156,7 +158,7 @@ for (const resource of rosResources) { 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), + 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" ? (action === "delete" ? 3 : 2) : 0) + (["bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 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 @@ -252,13 +254,24 @@ for (const resource of rosResources) { if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409) } } + const linkedVlan = await prepareGenericBridgeVlanChange({ serverId: sid, resource: resource.id, action, id, fields }, context) + if (linkedVlan) { + const captured = new Map(snapshots.map(snapshot => [snapshot.resource, snapshot])) + for (const snapshot of linkedVlan.snapshots) { + const previous = captured.get(snapshot.resource) + if (previous && (previous.fingerprint !== snapshot.fingerprint || previous.sourceProof !== snapshot.sourceProof)) throw new McpError("STATE_CONFLICT", "Зависимости VLAN изменились во время подготовки", 409) + captured.set(snapshot.resource, snapshot) + } + const objects = new Map([...references, ...linkedVlan.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object])) + return { ...linkedVlan, snapshots: [...captured.values()], objects: [...objects.values()] } + } 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}; порядок определяет принадлежность интерфейсов.`] : [])], + warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])], } }, }) @@ -270,7 +283,7 @@ for (const definition of [ { 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, + version: definition.id === "interfaces.update" ? 5 : 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) @@ -324,6 +337,21 @@ for (const action of ["create", "update", "delete", "binding_add", "binding_remo 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", "Сервер не разрешён") + if (action === "binding_remove" && !before?.bindings.some(binding => binding.id === input.bindingId)) { + throw new McpError("NOT_FOUND", "Привязка не найдена", 404) + } + if (action === "binding_add") { + try { + await validateUserBindingCreate(String(input.id), userBindingCreateSchema.parse(input.binding), { signal: context.signal }) + } catch (error) { + if (error instanceof UsersServiceError) { + if (error.status === 409) throw new McpError("STATE_CONFLICT", error.message, 409) + if (error.status === 400) throw new McpError("INVALID_INPUT", error.message, 400) + if (error.status === 404) throw new McpError("NOT_FOUND", error.message, 404) + } + throw error + } + } 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-права портала."] } }, @@ -352,16 +380,17 @@ function logFilters(input: Record, context: ActionContext) { } 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() + const generation = await accessGeneration() + let filters: ReturnType 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 + filters = logFilters({ ...input, from: input.from ?? state.from }, context) 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) } - } + } else filters = logFilters(input, context) 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 } }, @@ -376,7 +405,7 @@ for (const context of [false, true]) add(context ? "logs.context" : "logs.get", 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) }), { +add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, limit: logInput.shape.limit.default(200), cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), { read: async (input, context) => { const maintenance = context.logsFollowMaintenance const signal = context.signal @@ -387,16 +416,18 @@ add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:se assertActive() const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000 try { - const filters = logFilters(input, context), generation = await accessGeneration() + const generation = await accessGeneration() + let filters: ReturnType let cursor = "0" if (input.cursor) { try { const saved = JSON.parse(await decryptSecret(String(input.cursor))) - filters.from = saved.from + filters = logFilters({ ...input, from: input.from ?? saved.from }, context) 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 { + filters = logFilters(input, context) 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) { @@ -404,7 +435,7 @@ add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:se const current = await validatePrincipal(context.principal) assertActive() if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409) - const batch = await readLogStream(filters, cursor) + const batch = await readLogStream(filters, cursor, Number(input.limit)) assertActive() cursor = batch.cursor if (batch.entries.length || !batch.enabled || Date.now() >= until) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) } diff --git a/backend/src/modules/mcp/application-adapters-http.test.ts b/backend/src/modules/mcp/application-adapters-http.test.ts index 5459b0a6..93036c44 100644 --- a/backend/src/modules/mcp/application-adapters-http.test.ts +++ b/backend/src/modules/mcp/application-adapters-http.test.ts @@ -17,6 +17,7 @@ try{ 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("../../test/mcp-users-bindings.js")).verifyMcpUserBindings(actor) 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) diff --git a/backend/src/modules/mcp/bridge-vlan-actions.ts b/backend/src/modules/mcp/bridge-vlan-actions.ts new file mode 100644 index 00000000..da6e2eae --- /dev/null +++ b/backend/src/modules/mcp/bridge-vlan-actions.ts @@ -0,0 +1,153 @@ +import { z } from "zod" +import { isIP } from "node:net" +import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan" +import { buildBridgeVlanConfiguration, bridgeVlanCurrentDesired, BridgeVlanConfigurationError, type BridgeVlanState } from "../../services/bridge-vlan-configuration.js" +import { readRosSnapshot, routerClient, rosSnapshotSourceProof, type RosSnapshot, type RosStep } from "./resources.js" +import { verifyBridgeManagementChannel } from "./management.js" +import { McpError } from "./security.js" +import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js" +import type { ActionContext, ChangeEvidence } from "./action-types.js" +import type { add } from "./actions.js" + +const interfaceResources = ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"] +const resources = [...interfaceResources, "bridge_port", "bridge_vlan", "ipv4_address", "ipv6_address"] +export async function readBridgeVlanState(serverId: number, context: Pick): Promise<{ state: BridgeVlanState; snapshots: RosSnapshot[] }> { + const snapshots: RosSnapshot[] = [] + const active = async () => { if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Чтение конфигурации VLAN отменено", 409) } + // Bind every catalogue to the same configured device source. The executor + // rechecks these complete configuration snapshots before each native write. + for (const resource of resources) snapshots.push(await readRosSnapshot(serverId, resource, active, snapshots[0]?.sourceProof)) + const rows = (resource: string) => snapshots.find(snapshot => snapshot.resource === resource)!.rows + const state: BridgeVlanState = { + bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"), + interfaces: snapshots.filter(snapshot => interfaceResources.includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({ + resource: snapshot.resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled, + }))), + vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"), + } + return { state, snapshots } +} + +export async function prepareBridgeVlanConfiguration(raw: unknown, context: ActionContext): Promise { + const input = bridgeVlanConfigurationRequestSchema.parse(raw) + let management: Awaited> | undefined + try { management = await verifyBridgeManagementChannel(input.serverId) } + catch (error) { + // Only lack of a current alternate channel permits primary-path admission. + // Identity, transport and other verification failures stay fail-closed. + if (!(error instanceof McpError) || error.code !== "APPROVAL_REQUIRED") throw error + } + const { state, snapshots } = await readBridgeVlanState(input.serverId, context) + const primaryManagement = management === undefined + if (!management) { + const { server } = await routerClient(input.serverId) + if (!isIP(server.host)) throw new McpError("MANAGEMENT_UNSAFE", "Основной адрес управления должен быть буквальным IP, подтверждённым в конфигурации устройства", 409) + if (!snapshots[0]?.sourceProof || await rosSnapshotSourceProof(server) !== snapshots[0].sourceProof) throw new McpError("STATE_CONFLICT", "Источник основного управления изменился во время подготовки VLAN", 409) + const expiresAt = Math.min(Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime(), Date.parse(context.principal.credentialExpiresAt)) + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new McpError("APPROVAL_REQUIRED", "Нет действующего допуска для основного канала управления", 409) + management = { channelId: "primary", serverId: input.serverId, host: server.host, expiresAt: new Date(expiresAt).toISOString() } + } + try { + const plan = buildBridgeVlanConfiguration({ ...input, state, management, now: Date.now() }) + if (!plan.steps.length) throw new McpError("STATE_CONFLICT", "Конфигурация VLAN уже соответствует плану", 409) + return { + target: { scope: "servers", serverIds: [input.serverId] }, objects: plan.objects, + snapshots, steps: plan.steps.map(step => ({ kind: "ros" as const, step })), + requiresHuman: true, validUntil: management.expiresAt, + ...(primaryManagement ? {} : { managementChannels: [{ channelId: management.channelId, serverId: management.serverId, expiresAt: management.expiresAt }] }), + warnings: ["Связанный план изменяет VLAN memberships и PVID в указанном порядке; включение filtering выполняется последним.", + "Каждая подтверждённая запись проверяется до следующей. При неизвестном результате дальнейшие записи останавливаются.", + primaryManagement ? "Используется основной адрес управления; независимость канала не подтверждена. Обязательно подтверждение человеком; каждый префикс плана сохраняет подтверждённый management-доступ." + : "Проверяется конфигурация и доступность зарегистрированного management-канала. Передача пользовательского трафика требует отдельной сетевой проверки."], + } + } catch (error) { + if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409) + throw error + } +} + +/** A legacy single-row tool must pass the same effective-state checks, but may + * not acquire authority to edit other resource kinds through this delegation. */ +export async function prepareGenericBridgeVlanChange(step: RosStep, context: ActionContext): Promise { + const sensitive = step.resource === "bridge" ? ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"] : step.resource === "bridge_port" + ? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"] : ["bridge", "vlan-ids", "tagged", "untagged", "disabled"] + if (!["bridge", "bridge_port", "bridge_vlan"].includes(step.resource) || step.resource === "bridge" && step.action !== "update" + || step.action === "update" && !Object.keys(step.fields).some(key => sensitive.includes(key))) return undefined + const { state } = await readBridgeVlanState(step.serverId, context) + const rows = step.resource === "bridge" ? state.bridges : step.resource === "bridge_port" ? state.ports : state.vlans + const row = rows.find(item => item[".id"] === step.id) + const bridgeName = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge + const bridges = state.bridges.filter(item => item.name === bridgeName) + if (bridges.length !== 1 || step.resource !== "bridge" && row && row.bridge !== bridgeName) throw new McpError("STATE_CONFLICT", "Смена bridge требует отдельных явных планов удаления и добавления порта", 409) + const bridgeId = String(bridges[0][".id"]) + try { + const desired = bridgeVlanCurrentDesired(state, bridgeId) + const bool = (value: unknown): boolean => { + if (value === "true" || value === true) return true + if (value === "false" || value === false) return false + throw new McpError("INVALID_INPUT", "Нужны явные параметры порта VLAN", 400) + } + const ref = (name: unknown) => { + const matches = state.interfaces.filter(iface => iface.name === name) + if (matches.length !== 1) throw new McpError("INVALID_INPUT", "Не подтверждён стабильный ID участника VLAN", 400) + return { resource: matches[0].resource, id: matches[0].id } + } + if (step.resource === "bridge") { + if (Object.keys(step.fields).some(key => !sensitive.includes(key))) throw new McpError("INVALID_INPUT", "Подготовьте свойства bridge и VLAN filtering отдельными планами", 400) + if (step.fields.pvid !== undefined) desired.pvid = Number(step.fields.pvid) + if (step.fields["vlan-filtering"] !== undefined) desired.filtering = bool(step.fields["vlan-filtering"]) + if (step.fields["frame-types"] !== undefined) desired.frameTypes = bridgeVlanConfigurationRequestSchema.shape.desired.shape.frameTypes.parse(step.fields["frame-types"]) + if (step.fields["ingress-filtering"] !== undefined) desired.ingressFiltering = bool(step.fields["ingress-filtering"]) + } else if (step.resource === "bridge_port") { + const index = desired.ports.findIndex(port => port.id === step.id) + if (step.action === "delete") { + if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404) + desired.ports.splice(index, 1); desired.removePortIds.push(step.id!) + } else { + const effective = { ...row, ...step.fields } + const port = { ...(step.action === "update" ? { id: step.id } : {}), interface: ref(effective.interface), pvid: Number(effective.pvid), + ingressFiltering: bool(effective["ingress-filtering"]), frameTypes: effective["frame-types"], disabled: bool(effective.disabled), + ...(effective.comment === undefined ? {} : { comment: effective.comment }) } + const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.ports.element.parse(port) + if (step.action === "create") desired.ports.push(parsed) + else { if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404); desired.ports[index] = parsed } + } + } else { + const index = desired.vlans.findIndex(vlan => vlan.id === step.id) + if (step.action === "delete") { + if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404) + desired.vlans.splice(index, 1); desired.removeVlanIds.push(step.id!) + } else { + const effective = { ...row, ...step.fields } + const members = (value: unknown) => value === undefined || value === "" ? [] : String(value).split(",").map(ref) + const vlan = { ...(step.action === "update" ? { id: step.id } : {}), vlanIds: effective["vlan-ids"], tagged: members(effective.tagged), untagged: members(effective.untagged), + disabled: effective.disabled === undefined && step.action === "create" ? false : bool(effective.disabled), ...(effective.comment === undefined ? {} : { comment: effective.comment }) } + const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.vlans.element.parse(vlan) + if (step.action === "create") desired.vlans.push(parsed) + else { if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404); desired.vlans[index] = parsed } + } + } + const evidence = await prepareBridgeVlanConfiguration({ serverId: step.serverId, bridgeId, desired }, context) + if (evidence.steps.length !== 1 || evidence.steps[0].kind !== "ros" || evidence.steps[0].step.resource !== step.resource || evidence.steps[0].step.action !== step.action || evidence.steps[0].step.id !== step.id) { + throw new McpError("APPROVAL_REQUIRED", "Для связанных изменений используйте явный план mm_bridge_vlan_configuration_plan с соответствующим разрешением", 409) + } + const native = evidence.steps[0].step + if (step.action === "update" && Object.entries(native.fields).some(([key, value]) => !Object.hasOwn(step.fields, key) + || (step.resource === "bridge_vlan" ? canonicalBridgeVlanValue(key, value) !== canonicalBridgeVlanValue(key, step.fields[key]) : value !== step.fields[key]))) { + throw new McpError("STATE_CONFLICT", "Состояние VLAN изменилось во время подготовки; незапрошенные поля не будут перезаписаны", 409) + } + return evidence + } catch (error) { + if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409) + throw error + } +} + +export function registerBridgeVlanActions(register: typeof add) { + register("network.bridge-vlan.read", "mm_bridge_vlan_configuration_get", "Полная конфигурация bridge VLAN и стабильные ID участников", "network", "mm:network:read", "read", "servers", z.object({ serverId: z.number().int().positive() }).strict(), { + read: async (input, context) => ({ state: (await readBridgeVlanState(Number(input.serverId), context)).state }), + }) + register("network.bridge-vlan.configure", "mm_bridge_vlan_configuration_plan", "Связанный план VLAN membership, PVID и bridge filtering", "network", "mm:network:write", "write", "servers", bridgeVlanConfigurationRequestSchema, { + prepare: prepareBridgeVlanConfiguration, + }) +} diff --git a/backend/src/modules/mcp/bridge-vlan-configuration-http.test.ts b/backend/src/modules/mcp/bridge-vlan-configuration-http.test.ts new file mode 100644 index 00000000..d504d4e1 --- /dev/null +++ b/backend/src/modules/mcp/bridge-vlan-configuration-http.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict" +process.env.AUTH_REQUIRED = "true" +process.env.AUTH_JWT_SECRET = "bridge-vlan-owned-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: "bridge-vlan-owner", email: "bridge@example.invalid", name: "Bridge VLAN owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin"], expiresAt: Date.now() + 3600000 } +let app: Awaited> | 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 updateMcpSettings({ enabled: true }, actor) + app = await buildApp({ startScheduler: false }); await app.ready() + await (await import("../../test/mcp-bridge-vlan-configuration.js")).verifyMcpBridgeVlanConfiguration(actor, app) +} finally { + await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation() + await (await import("../../services/traffic-flow-ingest.js")).stopTrafficFlowListenerForDatabaseOperation() + await app?.close(); await closePool() +} diff --git a/backend/src/modules/mcp/bridge-vlan-projection.test.ts b/backend/src/modules/mcp/bridge-vlan-projection.test.ts new file mode 100644 index 00000000..4781236d --- /dev/null +++ b/backend/src/modules/mcp/bridge-vlan-projection.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { configurationRows, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js" +import { createRosPrefix } from "./ros-prefix.js" +import { verifyRosStepSnapshots } from "./ros-step-verification.js" +import { fingerprint, McpError } from "./security.js" + +const snapshot = (resource: string, raw: ConfigurationRow[]): RosSnapshot => { + const rows = configurationRows(resourceById(resource), raw) + return { serverId: 1, resource, rows, fingerprint: fingerprint(rows), sourceProof: "a".repeat(64), projectionVersion: rosSnapshotVersion(resource) } +} +const membership = { ".id": "*1", bridge: "br", "vlan-ids": "20-22", tagged: "ether2,ether3", dynamic: "false", disabled: "false" } + +test("VLAN readback accepts equivalent VID/member sets and omitted empty fields, retaining exact native identity", () => { + const before = snapshot("bridge_vlan", [membership]) + const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "22,20,21", tagged: "ether3,ether2" }]) + const step: RosStep = { serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "20-22", tagged: "ether3,ether2", untagged: "", comment: "" } } + assert.equal(verifyRosStepSnapshots(step, {}, before, after), true) + assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20-23" }])), false) + assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, ".id": "*2" }])), false) + assert.throws(() => snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20,20-22" }]), /Недостоверная/) +}) + +test("PVID-generated rows may change while the sealed static VLAN configuration remains exact", async () => { + const port = { ".id": "*2", bridge: "br", interface: "ether2", pvid: "10", disabled: "false" } + const dynamic = { ".id": "*D", bridge: "br", "vlan-ids": "10", untagged: "ether2", dynamic: "true" } + const raw = new Map([["bridge_port", [port]], ["bridge_vlan", [membership, dynamic]]]) + const initial = [...raw].map(([resource, rows]) => snapshot(resource, rows)) + const read = async (_server: number, resource: string, active: () => Promise = async () => {}) => { await active(); return snapshot(resource, raw.get(resource)!) } + const prefix = createRosPrefix(initial, read) + await prefix.check() + raw.set("bridge_port", [{ ...port, pvid: "20" }]) + raw.set("bridge_vlan", [membership, { ...dynamic, ".id": "*E", "vlan-ids": "20" }]) + const step: RosStep = { serverId: 1, resource: "bridge_port", action: "update", id: "*2", fields: { pvid: "20" } } + await prefix.advance(step, {}, await read(1, "bridge_port")) + await prefix.check() + raw.set("bridge_vlan", [{ ...membership, tagged: "ether2" }, dynamic]) + await assert.rejects(prefix.check(), error => error instanceof McpError && error.code === "STATE_CONFLICT") +}) + +test("full VLAN ranges and long sparse VID sets stay representable in plans and inverse snapshots", () => { + const resource = resourceById("bridge_vlan") + const expanded = Array.from({ length: 4094 }, (_, index) => String(index + 1)).join(",") + const sparse = Array.from({ length: 2047 }, (_, index) => String(index * 2 + 1)).join(",") + assert.ok(sparse.length > 100) + for (const value of ["1-4094", sparse]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, true) + for (const value of ["0", "4095", "10-9", "10,10-12"]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, false) + const before = snapshot("bridge_vlan", [membership]) + const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": expanded }]) + assert.equal(after.rows[0]["vlan-ids"], "1-4094", "Native expansion must not overflow the public inverse DTO") + assert.equal(verifyRosStepSnapshots({ serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "1-4094" } }, {}, before, after), true) +}) + +test("unknown dynamic flags are retained; only explicit RouterOS-generated VLAN rows are excluded", () => { + assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "unknown" }]).rows.length, 1) + for (const dynamic of [true, "true", "yes"]) assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic }]).rows.length, 0) + assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "false" }]).rows.length, 1) +}) + +test("old bridge CPU, port and VLAN projection contracts cannot execute after semantic projection changes", () => { + for (const resource of ["bridge", "bridge_port", "bridge_vlan"]) { + const current = snapshot(resource, resource === "bridge_vlan" ? [membership] : [{ ".id": "*2", bridge: "br", interface: "ether2", pvid: "10" }]) + assert.equal(current.projectionVersion, 4) + assert.throws(() => createRosPrefix([{ ...current, projectionVersion: 3 }]), error => error instanceof McpError && error.code === "PLAN_EXPIRED") + } +}) +test("CPU frame admission and MVRP configuration are sealed, with no MVRP write permission", () => { + const bridge = { ".id": "*1", name: "br", pvid: "10", "vlan-filtering": "true", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false", running: "true" } + const original = snapshot("bridge", [bridge]) + for (const field of ["frame-types", "ingress-filtering", "mvrp"]) { + const changed = snapshot("bridge", [{ ...bridge, [field]: field === "frame-types" ? "admit-only-vlan-tagged" : "false" === bridge[field as keyof typeof bridge] ? "true" : "false" }]) + assert.notEqual(changed.fingerprint, original.fingerprint) + } + assert.equal(snapshot("bridge", [{ ...bridge, running: "false" }]).fingerprint, original.fingerprint) + assert.equal(resourceById("bridge").fields.safeParse({ mvrp: true }).success, false) +}) diff --git a/backend/src/modules/mcp/bridge-vlan-recovery.ts b/backend/src/modules/mcp/bridge-vlan-recovery.ts new file mode 100644 index 00000000..cfa999da --- /dev/null +++ b/backend/src/modules/mcp/bridge-vlan-recovery.ts @@ -0,0 +1,135 @@ +import { bridgeVlanStateSchema, type BridgeVlanDesired, type BridgeVlanState } from "@mmapp/contracts/bridge-vlan" +import { bridgeVlanCurrentDesired } from "../../services/bridge-vlan-configuration.js" +import type { ActionContext, ChangeEvidence } from "./action-types.js" +import { prepareBridgeVlanConfiguration, readBridgeVlanState } from "./bridge-vlan-actions.js" +import { acknowledgedCreateId, assertNoCallerCreateId, recoveryRosStepId } from "./native-create-identity.js" +import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js" +import { verifyRosStepSnapshots } from "./ros-step-verification.js" +import { decryptSecret, fingerprint, McpError } from "./security.js" + +type Checkpoint = { step_index: number; before_sealed: string; after_sealed: string | null } +type Intended = { step_index: number; state: string; result: unknown } +const key = (snapshot: Pick) => `${snapshot.serverId}:${snapshot.resource}` +function conflict(message: string): never { throw new McpError("STATE_CONFLICT", message, 409) } + +export function needsBridgeVlanRecovery(sourceAction: string, evidence: ChangeEvidence): boolean { + if (sourceAction === "network.bridge-vlan.configure") return true + return evidence.steps.some(change => { + if (change.kind !== "ros") return false + const step = change.step + if (step.resource === "bridge") return step.action === "update" && Object.keys(step.fields).some(field => ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"].includes(field)) + const sensitive = step.resource === "bridge_port" ? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"] + : step.resource === "bridge_vlan" ? ["bridge", "vlan-ids", "tagged", "untagged", "disabled"] : [] + return sensitive.length > 0 && (step.action !== "update" || Object.keys(step.fields).some(field => sensitive.includes(field))) + }) +} + +function validSnapshot(snapshot: RosSnapshot): boolean { + return snapshot.projectionVersion === rosSnapshotVersion(snapshot.resource) && !!snapshot.sourceProof && Array.isArray(snapshot.rows) && snapshot.fingerprint === fingerprint(snapshot.rows) +} +function sameSnapshot(left: RosSnapshot, right: RosSnapshot): boolean { + return validSnapshot(left) && validSnapshot(right) && key(left) === key(right) && left.fingerprint === right.fingerprint && left.sourceProof === right.sourceProof +} +function stateFromSnapshots(serverId: number, snapshots: Map): BridgeVlanState { + const rows = (resource: string) => { + const snapshot = snapshots.get(key({ serverId, resource })) + if (!snapshot || !validSnapshot(snapshot)) conflict("Нет полного исходного снимка bridge VLAN с актуальной проекцией") + return snapshot.rows + } + return bridgeVlanStateSchema.parse({ bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"), + vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"), + interfaces: ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"].flatMap(resource => rows(resource).map(row => ({ resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled }))), + }) +} + +/** Fresh typed inverse of a completely verified transition. Deleted port/VLAN + * rows are recreated without their old IDs; new rows are removed only by ACK. */ +export async function prepareBridgeVlanRecovery(input: { + operationId: string; evidence: ChangeEvidence; checkpoints: Checkpoint[]; intended: Intended[]; +}, context: ActionContext): Promise { + const { operationId, evidence, checkpoints, intended } = input + if (!evidence.steps.length || evidence.steps.some(change => change.kind !== "ros" || !["bridge", "bridge_port", "bridge_vlan"].includes(change.step.resource))) { + throw new McpError("UNSUPPORTED_CAPABILITY", "Обратный VLAN план не поддерживает смешанные ресурсы", 409) + } + const native = evidence.steps.map(change => (change as { kind: "ros"; step: RosStep }).step) + if (native.some(step => step.resource === "bridge" && step.action !== "update")) { + throw new McpError("UNSUPPORTED_CAPABILITY", "Создание или удаление самого bridge не поддерживает типизированный обратный VLAN план", 409) + } + const serverIds = [...new Set(native.map(step => step.serverId))] + if (serverIds.length !== 1 || evidence.target.scope !== "servers" || evidence.target.serverIds.length !== 1 || evidence.target.serverIds[0] !== serverIds[0]) conflict("Нет однозначного сервера исходного VLAN плана") + const serverId = serverIds[0] + if (!context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "VLAN сервер вне текущего допуска") + if (intended.length !== native.length || checkpoints.length !== native.length || new Set(intended.map(item => item.step_index)).size !== native.length || new Set(checkpoints.map(item => item.step_index)).size !== native.length) conflict("Нет полного набора подтверждённых шагов VLAN") + const baseline = new Map() + for (const snapshot of evidence.snapshots) { + if (snapshot.serverId !== serverId || !validSnapshot(snapshot) || baseline.has(key(snapshot))) conflict("Исходные VLAN снимки или их версии не подтверждены") + baseline.set(key(snapshot), snapshot) + } + const baselineState = stateFromSnapshots(serverId, baseline), expected = new Map(baseline) + const created: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = [] + const deleted: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = [] + for (let index = 0; index < native.length; index++) { + const step = native[index], checkpoint = checkpoints.find(item => item.step_index === index), receipt = intended.find(item => item.step_index === index) + assertNoCallerCreateId(step) + if (!checkpoint?.before_sealed || !checkpoint.after_sealed || receipt?.state !== "verified") conflict("Нет полного проверенного checkpoint VLAN; требуется ручная сверка") + const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot + const after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot + const previous = expected.get(key({ serverId, resource: step.resource })) + if (!previous || !sameSnapshot(previous, before) || !validSnapshot(after) || before.sourceProof !== after.sourceProof || !verifyRosStepSnapshots(step, receipt.result, before, after)) conflict("Исходный переход VLAN или источник не подтверждён") + if (step.action === "create") { + const ack = acknowledgedCreateId(receipt.result), actual = recoveryRosStepId(step, before.rows, after.rows) + if (!ack || actual !== ack || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Нет точного ACK и checkpoint ID созданного VLAN объекта") + created.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: ack }) + } else if (step.action === "delete") { + if (!step.id || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Не подтверждён ID удалённого VLAN объекта") + deleted.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: step.id }) + } + expected.set(key(after), after) + } + const bridgeNames = new Set(native.map(step => { + const rows = step.resource === "bridge" ? baselineState.bridges : step.resource === "bridge_port" ? baselineState.ports : baselineState.vlans + const row = rows.find(item => item[".id"] === step.id) + const name = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge + if (typeof name !== "string" || !name) conflict("Не подтверждён исходный bridge VLAN") + return name + })) + const bridges = baselineState.bridges.filter(row => bridgeNames.has(String(row.name))) + if (bridgeNames.size !== 1 || bridges.length !== 1) conflict("Обратный VLAN план требует один однозначный bridge") + const bridgeId = String(bridges[0][".id"]), desired: BridgeVlanDesired = bridgeVlanCurrentDesired(baselineState, bridgeId) + for (const item of deleted) { + const terminal = expected.get(key({ serverId, resource: item.resource })) + const original = item.resource === "bridge_port" ? desired.ports.find(row => row.id === item.id) : desired.vlans.find(row => row.id === item.id) + if (!original || !terminal || terminal.rows.some(row => row[".id"] === item.id)) conflict("Нет подтверждённого удаления исходной VLAN identity") + delete original.id + } + for (const item of created) { + const originalRows = baseline.get(key({ serverId, resource: item.resource }))!.rows + const terminalRows = expected.get(key({ serverId, resource: item.resource }))!.rows + if (originalRows.some(row => row[".id"] === item.id) || !terminalRows.some(row => row[".id"] === item.id)) conflict("ACK ID VLAN не является подтверждённым новым объектом") + if (item.resource === "bridge_port") desired.removePortIds.push(item.id) + else desired.removeVlanIds.push(item.id) + } + const compare = async (snapshots: RosSnapshot[]): Promise => { + const captured = new Map(snapshots.map(snapshot => [key(snapshot), snapshot])) + for (const snapshot of snapshots) { + const terminal = expected.get(key(snapshot)) + if (!terminal || !sameSnapshot(terminal, snapshot)) conflict("После операции изменились VLAN конфигурация, зависимости или источник; обратный план запрещён") + } + for (const [resourceKey, terminal] of expected) { + const current = captured.get(resourceKey) ?? await readRosSnapshot(serverId, terminal.resource, undefined, terminal.sourceProof) + if (!sameSnapshot(terminal, current)) conflict("После операции обнаружен drift зависимости VLAN; обратный план запрещён") + captured.set(resourceKey, current) + } + return [...captured.values()] + } + await compare((await readBridgeVlanState(serverId, context)).snapshots) + // Reconstruct through the same model, with a freshly verified management + // channel. Old receipts never confer authority to run the inverse. + const inverse = await prepareBridgeVlanConfiguration({ serverId, bridgeId, desired }, context) + const snapshots = await compare(inverse.snapshots) + const objects = new Map([...evidence.objects, ...inverse.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object])) + return { ...inverse, snapshots, objects: [...objects.values()], requiresHuman: true, + warnings: [`Типизированный обратный VLAN план операции ${operationId}; все исходные шаги и ACK проверены.`, ...inverse.warnings, + "Удалённые port/VLAN rows создаются с новыми ID RouterOS. Unknown операции не повторяются; восстановление самого bridge не поддерживается."], + } +} diff --git a/backend/src/modules/mcp/integration.test.ts b/backend/src/modules/mcp/integration.test.ts index 5269834e..f1d3a7a1 100644 --- a/backend/src/modules/mcp/integration.test.ts +++ b/backend/src/modules/mcp/integration.test.ts @@ -2954,17 +2954,41 @@ try { await (await import("../../test/mcp-host-files.js")).verifyMcpHostFiles(actor,app) const readinessAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness handshake", scope: { ...input.scope, operationIds: ["servers.list"] } }), actor) const readinessHeaders = { authorization: "Bearer " + jwt } + const simulatorHeaders = { authorization: "Bearer " + await new SignJWT({ sub: "policy-preview-admin", apps: ["mm"], permissions: ["mm:mcp:admin"] }) + .setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) } + const policyPreview = (payload: Record) => app!.inject({ method: "POST", url: "/api/mcp/policy/preview", headers: simulatorHeaders, payload }) + const simulatedServerRead = { connectionId: readinessAccess.connection.id, operationId: "servers.list", target: { scope: "servers", serverIds: [1] } } + const simulation = await policyPreview(simulatedServerRead) + assert.equal(simulation.statusCode, 200, simulation.body) + assert.equal(simulation.json().result.decision, "allowed", "an administrator simulates the stored owner's rights, not the administrator's rights") + assert.equal(simulation.json().executionAvailable, false) + assert.equal((await policyPreview({ ...simulatedServerRead, ownerPermissions: ["mm:settings:admin"] })).statusCode, 400) const ready = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} }) assert.equal(ready.statusCode, 200, ready.body) assert.equal(ready.json().ok, true, ready.body); assert.equal(ready.json().checks.protocolHandshake, true) assert.equal(ready.json().protocolProbe.status, "passed"); assert.equal(ready.json().protocolProbe.clientCredentialsVerified, false) + assert.equal(ready.json().protocolProbe.capabilitiesRead, "skipped") assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readinessAccess.connection.id])).rows[0].n, 0) assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readinessAccess.connection.id])).rows[0].n, 0) const duplicateReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} }) assert.equal(duplicateReady.statusCode, 429) await setConnectionStatus(readinessAccess.connection.id, "paused", actor) + const pausedSimulation = await policyPreview(simulatedServerRead) + assert.equal(pausedSimulation.statusCode, 200, pausedSimulation.body) + assert.equal(pausedSimulation.json().result.decision, "denied") + assert.ok(pausedSimulation.json().result.reasons.some((reason: { code: string }) => reason.code === "CONNECTION_INACTIVE")) const pausedReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} }) assert.equal(pausedReady.statusCode, 200); assert.equal(pausedReady.json().protocolProbe.status, "skipped") + const readableAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness capability read", scope: { ...input.scope, operationIds: ["servers.list", "capabilities.read"] } }), actor) + const readable = await app.inject({ method: "POST", url: `/api/mcp/connections/${readableAccess.connection.id}/check`, headers: readinessHeaders, payload: {} }) + assert.equal(readable.statusCode, 200, readable.body) + assert.equal(readable.json().ok, true, readable.body) + assert.equal(readable.json().protocolProbe.capabilitiesRead, "passed") + assert.equal(readable.json().protocolProbe.credentialType, "temporary-capabilities-read") + const readinessReads = (await pool.query("SELECT tool FROM mcp_requests WHERE connection_id=$1 AND tool NOT LIKE 'protocol:%'", [readableAccess.connection.id])).rows + assert.deepEqual(readinessReads.map(row => row.tool), ["mm_capabilities_get"]) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readableAccess.connection.id])).rows[0].n, 0) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readableAccess.connection.id])).rows[0].n, 0) const emptyGrantAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Readiness has no operation authority" }), actor) const { withMcpProbeCredential } = await import("./readiness.js") let endedProbeToken = "" @@ -2980,7 +3004,7 @@ try { assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows[0].n, 0) const readinessAudit = (await pool.query("SELECT payload FROM mcp_audit WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows assert.ok(!JSON.stringify(readinessAudit).includes(endedProbeToken)) - console.log("✓ Actual readiness initialize/tools-list over public HTTP: temporary empty grant, no tool authority, cleanup on success/error, cooldown and paused precheck skip") + console.log("✓ Actual readiness initialize/tools-list and optional capabilities read over public HTTP: narrow grant, cleanup on success/error, cooldown and paused precheck skip") const deviceConnection = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Web interfaces", scope: { ...input.scope, operationIds: ["ros.bridge.list", "ros.bridge.create"] } }), actor) const deviceHeaders = { authorization: "Bearer " + jwt } const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders }) diff --git a/backend/src/modules/mcp/logs-follow-sdk-cancellation-acceptance.ts b/backend/src/modules/mcp/logs-follow-sdk-cancellation-acceptance.ts index 63e58e96..ff06e890 100644 --- a/backend/src/modules/mcp/logs-follow-sdk-cancellation-acceptance.ts +++ b/backend/src/modules/mcp/logs-follow-sdk-cancellation-acceptance.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" import type { IncomingMessage, ServerResponse } from "node:http" import { setTimeout as delay } from "node:timers/promises" import type { FastifyInstance } from "fastify" @@ -12,7 +13,7 @@ export async function verifyLogsFollowSdkCancellation(app: FastifyInstance, endp const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Logs follow SDK cancellation", mode: "approval", permissions: ["mm:servers:read"], expiresAt: new Date(Math.min(actor.expiresAt ?? Infinity, Date.now() + 600_000)).toISOString(), - scope: { serverIds: [1], operationIds: ["logs.follow"], allowGlobal: false }, + scope: { serverIds: [1], operationIds: ["logs.follow", "logs.query"], allowGlobal: false }, }), actor) const client = new Client({ name: "logs-follow-sdk-cancellation", version: "1.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } }, @@ -76,6 +77,63 @@ export async function verifyLogsFollowSdkCancellation(app: FastifyInstance, endp release() await refused.catch(() => undefined) } + pool.query = originalQuery + const from = "2000-01-01T00:00:00Z", to = "2000-01-01T00:30:00Z" + const followOnce = (filters: { from?: string; cursor?: string }) => client.callTool({ + name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 0, to, ...filters }, + }) + const initial = await followOnce({ from }) + assert.ok(!initial.isError) + const cursor = (initial.structuredContent as { result?: { cursor?: unknown } } | undefined)?.result?.cursor + assert.ok(typeof cursor === "string" && cursor.length > 0) + assert.ok(!(await followOnce({ from, cursor })).isError) + assert.ok(!(await followOnce({ cursor })).isError, "omitting from must resume the historical cursor's period") + const changed = await followOnce({ from: "2000-01-01T00:01:00Z", cursor }) + assert.equal(changed.isError, true) + assert.match(JSON.stringify(changed), /RESET_REQUIRED/) + assert.doesNotMatch(JSON.stringify(changed), /"cursor"/) + console.log("✓ MCP logs follow: historical cursor resumes, changed from requires reset") + + const marker = `mcp-logs-cursor-${randomUUID()}` + const readFollowPage = async (options: { cursor?: string; limit?: number }) => { + const result = await client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, from, to, q: marker, waitSeconds: 0, ...options } }) + assert.ok(!result.isError) + return (result.structuredContent as { result: { entries: { id: string }[]; cursor: string } }).result + } + const start = await readFollowPage({}) + assert.deepEqual(start.entries, []) + try { + await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation) + SELECT 1,'cursor fixture','127.0.0.1','fixture','00:02:00',now(),$1::timestamptz+n*interval '2 minutes',ARRAY['system'],'info',$2,0 + FROM generate_series(1,2) AS n`, [from, marker]) + const defaultFollow = await readFollowPage({ cursor: start.cursor }) + assert.equal(defaultFollow.entries.length, 2) + const firstFollow = await readFollowPage({ cursor: start.cursor, limit: 1 }) + const secondFollow = await readFollowPage({ cursor: firstFollow.cursor, limit: 1 }) + assert.equal(firstFollow.entries.length, 1) + assert.equal(secondFollow.entries.length, 1) + assert.deepEqual([...firstFollow.entries, ...secondFollow.entries].map(row => row.id), defaultFollow.entries.map(row => row.id)) + assert.deepEqual((await readFollowPage({ cursor: secondFollow.cursor, limit: 1 })).entries, []) + console.log("✓ MCP logs follow: requested batch limit and lossless cursor progression") + const query = (filters: { from?: string; cursor?: string }) => client.callTool({ + name: "mm_logs_query", arguments: { serverId: 1, q: marker, limit: 1, to, ...filters }, + }) + type Page = { entries: { id: string }[]; nextCursor: string | null } + const first = await query({ from }) + assert.ok(!first.isError) + const firstPage = (first.structuredContent as { result: Page }).result + assert.equal(firstPage.entries.length, 1) + assert.ok(firstPage.nextCursor) + const next = await query({ cursor: firstPage.nextCursor }) + assert.ok(!next.isError) + const nextPage = (next.structuredContent as { result: Page }).result + assert.equal(nextPage.entries.length, 1) + assert.notEqual(nextPage.entries[0].id, firstPage.entries[0].id) + assert.equal(nextPage.nextCursor, null) + console.log("✓ MCP logs query: historical pagination resumes without resending from") + } finally { + await pool.query("DELETE FROM router_log_entries WHERE server_id=1 AND message=$1", [marker]) + } } finally { bound.abort() pool.query = originalQuery diff --git a/backend/src/modules/mcp/management.ts b/backend/src/modules/mcp/management.ts index 849df4f3..e44fef4b 100644 --- a/backend/src/modules/mcp/management.ts +++ b/backend/src/modules/mcp/management.ts @@ -41,7 +41,7 @@ export async function registerManagementChannel(raw: unknown, actor: AuthUser) { }) return { id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" } } -export async function verifyManagementChannel(serverId: number, requiredId?: string) { +async function verifiedManagementChannel(serverId: number, requiredId?: string) { const { rows: [row] } = await pool.query("SELECT * FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [serverId]) if (!row || requiredId && row.id !== requiredId || row.generation !== await accessGeneration()) throw new McpError("APPROVAL_REQUIRED", "Нет актуального проверенного независимого канала управления", 409) const config = JSON.parse(await decryptSecret(row.config_sealed)) as MikrotikConnectParams @@ -50,7 +50,17 @@ export async function verifyManagementChannel(serverId: number, requiredId?: str if (hashes.some(value => value !== row.identity_hash)) throw new McpError("STATE_CONFLICT", "Идентичность основного или management-канала изменилась", 409) const changed = await pool.query("UPDATE mcp_management_channels SET verified_at=now() WHERE id=$1 AND revoked_at IS NULL AND expires_at>now() RETURNING id", [row.id]) if (!changed.rowCount) throw new McpError("APPROVAL_REQUIRED", "Допуск management-канала был отозван во время проверки", 409) - return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string } + return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string, host: config.host } +} +export async function verifyManagementChannel(serverId: number, requiredId?: string) { + const { host: _host, ...receipt } = await verifiedManagementChannel(serverId, requiredId) + return receipt +} +/** Internal VLAN preparation uses the verified endpoint to trace its actual + * address/interface. It is not accepted from tool input or returned in the + * public channel receipt. */ +export async function verifyBridgeManagementChannel(serverId: number, requiredId?: string) { + return verifiedManagementChannel(serverId, requiredId) } export async function managementEvidence(serverIds: number[]) { try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } } diff --git a/backend/src/modules/mcp/native-create-identity.test.ts b/backend/src/modules/mcp/native-create-identity.test.ts index 634567eb..b7ee3739 100644 --- a/backend/src/modules/mcp/native-create-identity.test.ts +++ b/backend/src/modules/mcp/native-create-identity.test.ts @@ -11,7 +11,7 @@ const creates = rosResources.filter(resource => !resource.readOnly && resource.c assert.equal(creates.length, 30) for (const resource of creates) { const action = findAction(`mm_${resource.id}_create_plan`) - assert.equal(action.operation.version, ["ospf_template", "ipsec_peer"].includes(resource.id) ? 5 : ["wireguard", "vrf"].includes(resource.id) ? 4 : 3) + assert.equal(action.operation.version, ["ospf_template", "ipsec_peer", "vrf"].includes(resource.id) ? 5 : ["wireguard", "bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 4 : 3) assert.equal(action.schema.safeParse({ serverId: 1, fields: {} }).success, true) for (const id of ["*OLD", undefined, null, ""]) { const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id }) diff --git a/backend/src/modules/mcp/operation-revisions.test.ts b/backend/src/modules/mcp/operation-revisions.test.ts index 0f6c3ddc..75873a24 100644 --- a/backend/src/modules/mcp/operation-revisions.test.ts +++ b/backend/src/modules/mcp/operation-revisions.test.ts @@ -38,7 +38,7 @@ assert.throws(() => action.schema.parse({ serverId: 1, expectedServerProof: "for assert.throws(() => action.schema.parse({ serverId: 1, version: 1 })) assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4") assert.equal(findAction("mm_routing_measurement_plan").operation.version, 2) -assert.equal(findAction("mm_operation_recovery_plan").operation.version, 7) +assert.equal(findAction("mm_operation_recovery_plan").operation.version, 8) assert.equal(findAction("mm_ospf_template_create_plan").operation.version, 5) assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4) assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3) @@ -49,11 +49,11 @@ assert.equal(findAction("mm_wireguard_update_plan").operation.version, 3) assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3) assert.equal(findAction("mm_wireguard_interface_create_plan").operation.version, 3) assert.equal(findAction("mm_wireguard_create_plan").operation.version, 4) -assert.equal(findAction("mm_interfaces_update_plan").operation.version, 4) +assert.equal(findAction("mm_interfaces_update_plan").operation.version, 5) assert.equal(findAction("mm_ipsec_peer_delete_plan").operation.version, 3) assert.equal(findAction("mm_ipsec_peer_update_plan").operation.version, 3) assert.equal(findAction("mm_certificate_delete_plan").operation.version, 3) -for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 7], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 3], ["mm_firewall_rule_update_plan", 2]] as const) { +for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 8], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 4], ["mm_firewall_rule_update_plan", 2]] as const) { const routed = findAction(tool) assert.equal(routed.operation.version, version) assert.equal(findMcpOperation(routed.operation.id)?.version, version) diff --git a/backend/src/modules/mcp/ospf-owner-graph.test.ts b/backend/src/modules/mcp/ospf-owner-graph.test.ts index 60c257eb..ce29db8c 100644 --- a/backend/src/modules/mcp/ospf-owner-graph.test.ts +++ b/backend/src/modules/mcp/ospf-owner-graph.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js" import { fingerprint, McpError, redact } from "./security.js" import { createRosPrefix } from "./ros-prefix.js" -import { ROS_SNAPSHOT_VERSION, type RosSnapshot } from "./resources.js" +import { rosSnapshotVersion, type RosSnapshot } from "./resources.js" const ids = [1, 2, 3] const graph = { family: "ipv4" as const, sourceSecretProofs: ids.map(serverId => ({ serverId, proof: fingerprint({ serverId }) })), instances: ids.map(serverId => ({ serverId, fingerprint: fingerprint({ serverId, native: "instances" }) })) } @@ -54,7 +54,7 @@ const catalogAuthorize = async () => { await sourceGuard() } const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }] -const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof: "a".repeat(64) } +const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: rosSnapshotVersion("bridge"), sourceProof: "a".repeat(64) } reads = [] const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => { // Match readRosSnapshot's normal three grant/source fences without polling a diff --git a/backend/src/modules/mcp/readiness-http.test.ts b/backend/src/modules/mcp/readiness-http.test.ts index 04ab90ac..0292a8fa 100644 --- a/backend/src/modules/mcp/readiness-http.test.ts +++ b/backend/src/modules/mcp/readiness-http.test.ts @@ -21,7 +21,7 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne try { for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) { mode = state - const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", 300) + const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 300 }) assert.equal(result.status, "failed", state) assert.ok(performance.now() - started < 3000, state) assert.ok(!JSON.stringify(result).includes("private-readiness-fixture")) @@ -29,3 +29,49 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne assert.equal(forwarded, 0); assert.ok(requests >= 5) } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) } }) + +test("capabilities readiness refuses widened scope and checks the tool result", async () => { + const capability = { id: "capabilities.read", toolName: "mm_capabilities_get" } + const tool = { name: capability.toolName, inputSchema: { type: "object" } } + let mode = "extra-tool", calls = 0 + const server = createServer(async (req, res) => { + if (req.method !== "POST") { res.writeHead(405).end(); return } + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk) + const { id, method, params } = JSON.parse(Buffer.concat(chunks).toString()) + if (id === undefined) { res.writeHead(202).end(); return } + let result: unknown + if (method === "initialize") result = { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1.0.0" } } + if (method === "tools/list") result = { tools: mode === "extra-tool" ? [tool, { ...tool, name: "mm_servers_list" }] : [tool] } + if (method === "tools/call") { + calls++ + assert.equal(params.name, capability.toolName) + if (mode === "timeout") { res.writeHead(200, { "content-type": "application/json" }).write("{"); return } + const operations = mode === "extra-operation" ? [capability, { id: "servers.list" }] : [capability] + result = { content: [], isError: mode === "tool-error", structuredContent: { result: { schemaVersion: 1, operations } } } + } + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id, result })) + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + const endpoint = new URL(`http://127.0.0.1:${(server.address() as { port: number }).port}/mcp`) + const probe = () => runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 400, readCapabilities: true }) + try { + const widened = await probe() + assert.equal(widened.reason, "SCOPE_NOT_ENFORCED") + assert.equal(widened.capabilitiesRead, "skipped") + assert.equal(calls, 0) + for (const state of ["tool-error", "extra-operation", "timeout"]) { + mode = state + const result = await probe() + assert.equal(result.status, "failed", state) + assert.equal(result.capabilitiesRead, "failed", state) + assert.equal(result.reason, state === "timeout" ? "TIMEOUT" : "CAPABILITIES_READ_FAILED", state) + } + mode = "valid" + const valid = await probe() + assert.equal(valid.status, "passed") + assert.equal(valid.capabilitiesRead, "passed") + assert.equal(valid.clientCredentialsVerified, false) + assert.equal(calls, 4) + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) } +}) diff --git a/backend/src/modules/mcp/readiness.ts b/backend/src/modules/mcp/readiness.ts index 299427a9..41dc88ea 100644 --- a/backend/src/modules/mcp/readiness.ts +++ b/backend/src/modules/mcp/readiness.ts @@ -9,15 +9,19 @@ export interface ProtocolProbe { status: "passed" | "failed" | "skipped"; elapsedMs: number; reason?: string; - credentialType: "temporary-no-operations"; + credentialType: "temporary-no-operations" | "temporary-capabilities-read"; + capabilitiesRead: "passed" | "failed" | "skipped"; clientCredentialsVerified: false; } -/** A temporary access credential may negotiate metadata, but its empty grant - * permits no tools. The ordinary connection credentials are never recovered. */ -export async function withMcpProbeCredential(connectionId: string, actor: AuthUser, work: (token: string) => Promise): Promise { +const capabilitiesOperation = "capabilities.read" +const capabilitiesTool = "mm_capabilities_get" + +/** The probe may read its own narrowed capabilities when the connection grants + * that operation. The ordinary connection credentials are never recovered. */ +export async function withMcpProbeCredential(connectionId: string, actor: AuthUser, work: (token: string, readCapabilities: boolean) => Promise): Promise { const credentialId = randomUUID(), grantId = randomUUID(), token = randomToken("access") - await transaction(async client => { + const readCapabilities = await transaction(async client => { await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [connectionId]) const connection = await getConnection(connectionId, client) assertConnectionVisible(connection, actor) @@ -27,14 +31,16 @@ export async function withMcpProbeCredential(connectionId: string, actor: Aut const expired = (await client.query("DELETE FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe' AND expires_at<=now() RETURNING id", [connectionId])).rows.map(row => row.id) if (expired.length) await client.query("DELETE FROM mcp_grants WHERE connection_id=$1 AND payload->>'credentialId'=ANY($2::text[])", [connectionId, expired]) const expiresAt = new Date(Math.min(Date.now() + 20000, Date.parse(connection.policy.expiresAt), connection.owner_valid_until.getTime())).toISOString() - const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: [], allowGlobal: false } }) + const allowed = connection.policy.scope.operationIds.includes(capabilitiesOperation) + const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: allowed ? [capabilitiesOperation] : [], allowGlobal: false } }) await client.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at,audience,client_id) VALUES($1,$2,$3,'access',$4,$5,'mcp-readiness-probe')", [credentialId, connectionId, tokenDigest(token), expiresAt, canonicalMcpUrl().href]) await client.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [grantId, connectionId, JSON.stringify({ ...grant, credentialId })]) await validatePrincipal({ connectionId, credentialId, credentialExpiresAt: expiresAt }, client) await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt }) + return allowed }) let completed = false - try { const result = await work(token); completed = true; return result } + try { const result = await work(token, readCapabilities); completed = true; return result } finally { await transaction(async client => { await client.query("DELETE FROM mcp_grants WHERE id=$1 AND connection_id=$2", [grantId, connectionId]) @@ -45,11 +51,12 @@ export async function withMcpProbeCredential(connectionId: string, actor: Aut } /** One deadline covers negotiation, metadata and HTTP response bodies. */ -export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutMs = 8000): Promise { +export async function runMcpProtocolProbe(endpoint: URL, token: string, { timeoutMs = 8000, readCapabilities = false } = {}): Promise { const started = performance.now(), controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) const client = new Client({ name: "MikrotikManager readiness", version: "1.0.0" }) - const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: "temporary-no-operations", clientCredentialsVerified: false }) + let capabilitiesRead: ProtocolProbe["capabilitiesRead"] = "skipped" + const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: readCapabilities ? "temporary-capabilities-read" : "temporary-no-operations", capabilitiesRead, clientCredentialsVerified: false }) try { const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow", fetch: async (url, init) => { @@ -67,13 +74,22 @@ export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutM } }) await client.connect(transport, { timeout: timeoutMs }) const tools = await client.listTools({}, { timeout: timeoutMs }) - if (tools.tools.length !== 0 || tools.nextCursor) return result("failed", "SCOPE_NOT_ENFORCED") + const expectedTools = readCapabilities ? [capabilitiesTool] : [] + if (tools.nextCursor || tools.tools.length !== expectedTools.length || tools.tools.some((tool, index) => tool.name !== expectedTools[index])) return result("failed", "SCOPE_NOT_ENFORCED") + if (readCapabilities) { + capabilitiesRead = "failed" + const call = await client.callTool({ name: capabilitiesTool, arguments: {} }, { timeout: timeoutMs }) + const output = (call.structuredContent as { result?: { schemaVersion?: unknown; operations?: unknown } } | undefined)?.result + const operations = output?.operations + if (call.isError || output?.schemaVersion !== 1 || !Array.isArray(operations) || operations.length !== 1 || operations[0]?.id !== capabilitiesOperation || operations[0]?.toolName !== capabilitiesTool) return result("failed", "CAPABILITIES_READ_FAILED") + capabilitiesRead = "passed" + } return result("passed") - } catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : "TRANSPORT_FAILED") } + } catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : capabilitiesRead === "failed" ? "CAPABILITIES_READ_FAILED" : "TRANSPORT_FAILED") } finally { clearTimeout(timer); controller.abort(); await client.close().catch(() => {}) } } export async function checkMcpProtocol(connectionId: string, actor: AuthUser) { const endpoint = canonicalMcpUrl() - return withMcpProbeCredential(connectionId, actor, token => runMcpProtocolProbe(endpoint, token)) + return withMcpProbeCredential(connectionId, actor, (token, readCapabilities) => runMcpProtocolProbe(endpoint, token, { readCapabilities })) } diff --git a/backend/src/modules/mcp/recovery-actions.ts b/backend/src/modules/mcp/recovery-actions.ts index a0226fa0..b9420048 100644 --- a/backend/src/modules/mcp/recovery-actions.ts +++ b/backend/src/modules/mcp/recovery-actions.ts @@ -6,13 +6,14 @@ import { decryptSecret, fingerprint, McpError } from "./security.js" import { effectiveMcpConnection } from "./store.js" import { findMcpService } from "./service-registry.js" import { findMcpOperation } from "./registry.js" -import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js" +import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js" import { verifyRosStepSnapshots } from "./ros-step-verification.js" import type { add } from "./actions.js" import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js" import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js" import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js" import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js" +import { needsBridgeVlanRecovery, prepareBridgeVlanRecovery } from "./bridge-vlan-recovery.js" async function source(operationId: string, context: ActionContext, allowServices = false) { const { rows: [row] } = await pool.query("SELECT o.*,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", [operationId, context.connection.id]) @@ -86,7 +87,7 @@ export function registerRecoveryActions(register: typeof add) { }, }) register("operations.recover", "mm_operation_recovery_plan", "Подготовить отдельный обратный план сетевой операции", "network", "mm:network:write", "write", "servers", z.object({ operationId: z.string().uuid() }).strict(), { - version: 7, + version: 8, target: async (input, context) => { const { evidence } = await source(String(input.operationId), context, true) if (evidence.steps.some(step => step.kind === "ros" && step.step.resource === "vrf")) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF не имеет проверенной обратной ревизии; используйте read-only сверку и отдельный approved план", 409) @@ -106,6 +107,7 @@ export function registerRecoveryActions(register: typeof add) { const checkpoints = (await pool.query("SELECT step_index,before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows const intended = (await pool.query("SELECT step_index,state,result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows if (!intended.length || intended.some(step => step.state !== "verified") || intended.length !== checkpoints.length || checkpoints.some(checkpoint => !checkpoint.after_sealed)) throw new McpError("STATE_CONFLICT", "Нет полного набора подтверждённых снимков; требуется ручная сверка", 409) + if (needsBridgeVlanRecovery(row.source_action, evidence)) return prepareBridgeVlanRecovery({ operationId: row.id, evidence, checkpoints, intended }, context) const snapshots = new Map(), steps: ChangeStep[] = [], objects: ChangeEvidence["objects"] = [...evidence.objects] for (const checkpoint of checkpoints) { const original = evidence.steps[checkpoint.step_index] @@ -113,7 +115,7 @@ export function registerRecoveryActions(register: typeof add) { const step = original.step, resource = resourceById(step.resource) if (!context.connection.policy.scope.serverIds.includes(step.serverId)) throw new McpError("SERVER_DENIED", "Зависимость вне допуска") const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot, after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot - if (before.projectionVersion !== ROS_SNAPSHOT_VERSION || after.projectionVersion !== ROS_SNAPSHOT_VERSION || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409) + if (before.projectionVersion !== rosSnapshotVersion(step.resource) || after.projectionVersion !== rosSnapshotVersion(step.resource) || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409) const key = `${step.serverId}:${step.resource}` if (!snapshots.has(key)) { const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof) @@ -143,7 +145,7 @@ export function registerRecoveryActions(register: typeof add) { const key = `${dependency.serverId}:${dependency.resource}` if (snapshots.has(key)) continue const current = await readRosSnapshot(dependency.serverId, dependency.resource, undefined, dependency.sourceProof) - if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== ROS_SNAPSHOT_VERSION) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409) + if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== rosSnapshotVersion(dependency.resource)) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409) snapshots.set(key, current) } objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId }))) diff --git a/backend/src/modules/mcp/resources.ts b/backend/src/modules/mcp/resources.ts index 48b8d68a..2488e653 100644 --- a/backend/src/modules/mcp/resources.ts +++ b/backend/src/modules/mcp/resources.ts @@ -5,14 +5,19 @@ import { eq } from "drizzle-orm" import { db } from "../../db/index.js" import { servers } from "../../db/schema.js" import { MikrotikClient } from "../../services/mikrotik.js" +import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js" import { McpError, fingerprint, privateSecretFingerprint } from "./security.js" import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js" import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js" import { canonicalVrfMembers } from "../../services/vrf-members.js" +import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js" const text = z.string().max(1000) const name = z.string().trim().min(1).max(100) const bool = z.boolean() +const vlanIds = z.string().min(1).max(16384).refine(value => { + try { canonicalBridgeVlanValue("vlan-ids", value); return true } catch { return false } +}, "Требуется непересекающийся список VLAN ID/диапазонов 1..4094") const integer = (min: number, max: number) => z.number().int().min(min).max(max) const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() } const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID") @@ -31,13 +36,15 @@ const route = { export interface RosResourceSpec { id: string; path: string; title: string; permission: string; fields: z.ZodObject; create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean + /** Native configuration sealed for drift checks, never accepted as writes. */ + observeFields?: readonly string[] } export const rosResources: RosResourceSpec[] = [ { id: "routing_filter", path: "/routing/filter/rule", title: "Фильтры маршрутизации", permission: "mm:filters", fields: z.object({ chain: text.optional(), rule: text.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: false, remove: false, readOnly: true }, { id: "ethernet", path: "/interface/ethernet", title: "Ethernet", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "auto-negotiation": bool.optional() }).strict(), create: false, remove: false }, - { id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] }, + { id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), "ingress-filtering": bool.optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), observeFields: ["mvrp"], create: true, remove: true, requiredOnCreate: ["name"] }, { id: "bridge_port", path: "/interface/bridge/port", title: "Порты bridge", permission: "mm:network", fields: z.object({ bridge: name.optional(), interface: name.optional(), pvid: integer(1, 4094).optional(), "ingress-filtering": bool.optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "interface"] }, - { id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": name.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] }, + { id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": vlanIds.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] }, { id: "vlan", path: "/interface/vlan", title: "VLAN", permission: "mm:network", fields: z.object({ ...common, interface: name.optional(), "vlan-id": integer(1, 4094).optional(), mtu: integer(68, 65535).optional(), "use-service-tag": bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interface", "vlan-id"] }, { id: "ipv4_address", path: "/ip/address", title: "IPv4-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), network: name.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] }, { id: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] }, @@ -70,6 +77,7 @@ export const rosResources: RosResourceSpec[] = [ export type ConfigurationRow = Record export interface RosStep { serverId: number; resource: string; action: "create" | "update" | "delete"; id?: string; fields: Record; placeBeforeId?: string; expectedCost?: number; expectedServerProof?: string } export const ROS_SNAPSHOT_VERSION = 3 +export const rosSnapshotVersion = (resource: string): number => ["bridge", "bridge_port", "bridge_vlan"].includes(resource) ? 4 : ROS_SNAPSHOT_VERSION export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string } export function resourceById(id: string): RosResourceSpec { const resource = rosResources.find(item => item.id === id) @@ -93,13 +101,21 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown): // RouterOS creates/removes IPv6 link-local rows with interface runtime state. // They cannot be configured independently and must not invalidate a sealed // inverse plan when its own GRE is disabled. Explicit addresses remain sealed. - const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address)))) + const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address))) + // These rows are generated from bridge/port PVID, not independently + // configurable VLAN entries. Their inputs remain sealed in bridge/ports. + && !(resource.id === "bridge_vlan" && [true, "true", "yes"].includes((row as ConfigurationRow).dynamic as boolean | string))) const selectedRows = configuration.map(row => { - const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields].includes(key))) + const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields, ...(resource.observeFields ?? [])].includes(key))) // RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables. // Normalize only this observed field; missing values must remain unknown. if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false" if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces) + if (["bridge_port", "bridge_vlan"].includes(resource.id) && selected.comment === undefined) selected.comment = "" + if (resource.id === "bridge_vlan") { + try { for (const field of ["vlan-ids", "tagged", "untagged"]) selected[field] = canonicalBridgeVlanValue(field, selected[field]) } + catch { throw new McpError("STATE_CONFLICT", "Недостоверная конфигурация VLAN membership", 409) } + } return selected }) // Reordering an ordered catalog changes configuration even with unchanged rows. @@ -118,10 +134,10 @@ export async function readRosSnapshot(serverId: number, resourceId: string, befo const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get(resource.path), async id => { await checkSource() return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" }) - }) : await client.get(resource.path) + }) : await readRouterOsSnapshotGet({ client, path: resource.path, assertCurrent: checkSource }) await checkSource() const rows = configurationRows(resource, payload) - return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof } + return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resourceId), sourceProof } } export async function rosSnapshotSourceProof(server: Awaited>["server"]): Promise { return privateSecretFingerprint(fingerprint({ id: server.id, enabled: server.enabled, host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, epoch: server.routingEndpointEpoch }), "mcp-ros-snapshot-source-v3") diff --git a/backend/src/modules/mcp/ros-prefix.ts b/backend/src/modules/mcp/ros-prefix.ts index 4506340b..bfdd86b8 100644 --- a/backend/src/modules/mcp/ros-prefix.ts +++ b/backend/src/modules/mcp/ros-prefix.ts @@ -1,11 +1,11 @@ import { fingerprint, McpError } from "./security.js" -import { readRosSnapshot, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js" +import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js" import { verifyRosStepSnapshots } from "./ros-step-verification.js" import { acknowledgedCreateId } from "./native-create-identity.js" const key = (snapshot: Pick) => `${snapshot.serverId}:${snapshot.resource}` function valid(snapshot: RosSnapshot): boolean { - if (snapshot.projectionVersion !== ROS_SNAPSHOT_VERSION || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false + if (snapshot.projectionVersion !== rosSnapshotVersion(snapshot.resource) || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false if (["ip_settings", "ipv6_settings"].includes(snapshot.resource)) return snapshot.rows.length === 1 && !!snapshot.rows[0] && typeof snapshot.rows[0] === "object" && !Array.isArray(snapshot.rows[0]) const ids = snapshot.rows.map(acknowledgedCreateId) return ids.every(id => id !== undefined) && new Set(ids).size === ids.length diff --git a/backend/src/modules/mcp/ros-step-verification.ts b/backend/src/modules/mcp/ros-step-verification.ts index 1969c4b1..be74d7ca 100644 --- a/backend/src/modules/mcp/ros-step-verification.ts +++ b/backend/src/modules/mcp/ros-step-verification.ts @@ -1,5 +1,6 @@ import { isDeepStrictEqual } from "node:util" import { acknowledgedCreateId } from "./native-create-identity.js" +import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js" import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js" function booleanValue(value: unknown): unknown { @@ -23,6 +24,9 @@ function requested(resource: RosResourceSpec, fields: RosStep["fields"], row: Co const schema = resource.fields.shape[key] if (!schema || typeof value !== "string" || !Object.hasOwn(row, key) || row[key] === undefined || row[key] === null) return false if (schema.safeParse(true).success) return ["true", "false"].includes(value) && booleanValue(row[key]) === (value === "true") + if (resource.id === "bridge_vlan" && ["vlan-ids", "tagged", "untagged"].includes(key)) { + try { return canonicalBridgeVlanValue(key, row[key]) === canonicalBridgeVlanValue(key, value) } catch { return false } + } return String(row[key]) === value }) } diff --git a/backend/src/modules/mcp/routing-community-scoped-acceptance.ts b/backend/src/modules/mcp/routing-community-scoped-acceptance.ts index fb249cbd..50757361 100644 --- a/backend/src/modules/mcp/routing-community-scoped-acceptance.ts +++ b/backend/src/modules/mcp/routing-community-scoped-acceptance.ts @@ -34,13 +34,15 @@ import { registerManagementChannel } from "./management.js" import { isSchedulerJobsPaused, pauseSchedulerJobsAndWait, resumeSchedulerJobs } from "../../services/scheduler-running.js" /** Disposable SQL + loopback HTTP only. Does not access or configure CHR. */ -export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalEffectsOnly?: boolean; originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" | "mixed-forward"; lateInverseAuthorityChange?: "edit" | "disable" | "grant" } = {}) { +export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalEffectsOnly?: boolean; controllerFailoverOnly?: boolean; originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" | "mixed-forward"; lateInverseAuthorityChange?: "edit" | "disable" | "grant" } = {}) { const sid = options.lateInverseAuthorityChange ? { edit: 131, disable: 132, grant: 133 }[options.lateInverseAuthorityChange] : 131 const writer: AuthUser = { ...actor, permissions: [...new Set([...actor.permissions, "mm:filters:write"])] } const initial = compileManagedFilterRules([{ community: "65000:10", gateway: "192.0.2.1", outIface: "", action: "route", description: "" }, { community: "65000:20", gateway: "192.0.2.5", outIface: "", action: "route", description: "" }]) const filters = ["a", "b"].map(key => ({ ".id": "*f" + key, chain: "custom-" + key, rule: initial, disabled: "false", comment: managedComment("NEVER_SCOPED_COMMENT_" + key), opaque: "NEVER_SCOPED_OPAQUE" })) let mode: "normal" | "ignored" | "lost" | "collateral" | "post-failed" | "post-unknown" = "normal", writes = 0, requests = 0 let controllerProbeUnknown = false + let controllerFailoverProbeSources: string[] | undefined + let controllerFailoverReserveHealthy = true let pendingPostCheckpoint = false, postCheckpointsObserved = 0 let afterForwardWrite: (() => Promise) | undefined let afterConfigurationCheckpoint: ((operationId: string) => Promise) | undefined @@ -65,6 +67,9 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: const effectConnection = { ".id": "*ca", name: "peer-a", instance: "default", vrf: "main", "routing-table": "tenant-a", afi: "ip", "input.filter": "custom-a", "output.filter-chain": "", templates: "", disabled: "false", "use-bfd": "false", "local.address": "198.18.0.1", "remote.address": "198.18.0.2", "remote.as": "65001" } + const { vrf: effectTransportVrf, ...effectSessionFields } = effectConnection + const effectSession = { ...effectSessionFields, ".id": "*sa", "local.afi": "ip", "remote.afi": "ip", established: "true", + "local.address": `${effectConnection["local.address"]}@${effectTransportVrf}`, "remote.address": `${effectConnection["remote.address"]}@${effectTransportVrf}` } const collections = (path: string): Record[] => ({ "/system/resource": [{ version: "7.20.1" }], "/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "true", disabled: "false", invalid: "false" })), @@ -72,10 +77,13 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: "/routing/bgp/connection": [effectConnection, { ...effectConnection, ".id": "*cb", name: "peer-b", "routing-table": "tenant-b", "input.filter": "custom-b" }], "/routing/bgp/instance": [{ ".id": "*i", name: "default", vrf: "main", "routing-table": "main", disabled: "false" }], "/routing/bgp/template": [], "/routing/filter/rule": filters, - "/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false", + "/ip/route": (controllerFailoverProbeSources ? [1, 9, 10, 11] : [1, 9]).map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false", "immediate-gw": "203.0.113." + value + "%wan-a", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })), "/interface": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*if" + index, name, disabled: "false", running: "true" })), - "/ip/address": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*ad" + index, interface: name, address: "198.51.100." + (index + 1) + "/32", disabled: "false", invalid: "false" })), + "/ip/address": [ + ...["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*ad" + index, interface: name, address: "198.51.100." + (index + 1) + "/32", disabled: "false", invalid: "false" })), + ...(controllerFailoverProbeSources ? [11, 12, 13].map(index => ({ ".id": "*ad" + index, interface: "wan-a", address: "198.51.100." + index + "/32", disabled: "false", invalid: "false" })) : []), + ], ...(options.originalEffectsOnly ? { "/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "", disabled: "false", invalid: "false" })), "/routing/bgp/connection": [effectConnection], @@ -83,7 +91,7 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: } : {}), ...(exposeEffectCatalogue || options.originalEffectsOnly ? { "/routing/route": effectRoutes(), - "/routing/bgp/session": [{ ...effectConnection, ".id": "*sa", "local.afi": "ip", "remote.afi": "ip", established: "true" }], + "/routing/bgp/session": [effectSession], } : {}), } as Record[]>)[path] ?? [] let requestFailure: { error: unknown } | undefined @@ -113,23 +121,27 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: if (req.method === "POST" && path.endsWith("/get")) { const row = collections(path.slice(0, -4)).find(row => row[".id"] === args[".id"]), value = row?.[args["value-name"]]; res.end(JSON.stringify(value === undefined ? [] : { ret: value })); return } if (req.method === "POST" && path === "/tool/ping") { if (pendingInversePost) { - const intents = (await pool.query("SELECT source_operation_id FROM mcp_scoped_recovery_intents WHERE state='intent'")).rows + assert.ok(connectionId) + const intents = (await pool.query("SELECT i.source_operation_id FROM mcp_scoped_recovery_intents i JOIN mcp_operations o ON o.id=i.recovery_operation_id WHERE i.state='intent' AND o.connection_id=$1", [connectionId])).rows assert.equal(intents.length, 1, "Post-inverse probe must follow one durable single-use inverse intent") pendingInversePost = false; inversePostChecks++ } if (pendingPostCheckpoint) { - const checkpoint = (await pool.query("SELECT c.configuration_sealed,c.configuration_proof FROM mcp_operation_checkpoints c JOIN mcp_operations o ON o.id=c.operation_id WHERE o.state='running' AND c.configuration_sealed IS NOT NULL")).rows + assert.ok(connectionId) + const checkpoint = (await pool.query("SELECT c.configuration_sealed,c.configuration_proof FROM mcp_operation_checkpoints c JOIN mcp_operations o ON o.id=c.operation_id WHERE o.state='running' AND c.configuration_sealed IS NOT NULL AND o.connection_id=$1", [connectionId])).rows assert.equal(checkpoint.length, 1, "Exact CONFIG_APPLIED must commit before the post-change network probe") const packet = JSON.parse(await decryptSecret(checkpoint[0].configuration_sealed)) assert.equal(packet.phase, "CONFIG_APPLIED"); assert.equal(packet.originalRule, initial) - assert.equal(packet.originalCandidateId, "a"); assert.equal(packet.desiredCandidateId, "b") + assert.equal(packet.originalCandidateId, "a"); assert.equal(packet.desiredCandidateId, controllerFailoverProbeSources ? "d" : "b") assert.equal(packet.desiredRule, filters.find(row => row[".id"] === packet.nativeId)!.rule) assert.ok(checkpoint[0].configuration_proof); pendingPostCheckpoint = false; postCheckpointsObserved++ const sharedCheck = afterConfigurationCheckpoint; afterConfigurationCheckpoint = undefined await sharedCheck?.(packet.operationId) } observedVrfs.add(args.vrf) - res.end(JSON.stringify(controllerProbeUnknown || mode === "post-unknown" && writes ? [{ message: "unsupported fixture output" }] : [{ sent: "3", received: mode === "post-failed" && writes ? "0" : "3", "packet-loss": mode === "post-failed" && writes ? "100" : "0", "avg-rtt": "1ms" }])) + if (controllerFailoverProbeSources) controllerFailoverProbeSources.push(args["src-address"]) + const probeFailed = controllerFailoverProbeSources && (!controllerFailoverReserveHealthy || args["src-address"] !== "198.51.100.13") || mode === "post-failed" && writes + res.end(JSON.stringify(controllerProbeUnknown || mode === "post-unknown" && writes ? [{ message: "unsupported fixture output" }] : [{ sent: "3", received: probeFailed ? "0" : "3", "packet-loss": probeFailed ? "100" : "0", "avg-rtt": "1ms" }])) return } if (req.method === "POST" && path === "/tool/traceroute") { observedVrfs.add(args.vrf); res.end(JSON.stringify([3, 4].map(index => ({ address: "198.51.100." + index, sent: "1", loss: "0" })))); return } @@ -264,6 +276,74 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: return operation } finally { observer.close() } } + const acceptControllerFailover = async () => { + reset(); await refresh(); exposeEffectCatalogue = true; originalEffectCase = "transition" + controllerFailoverProbeSources = [] + const profileId = randomUUID(), initialWrites = writes + persistentProfileIds.push(profileId) + try { + const configured = profile("a"), original = configured.candidates[0] + configured.candidates = [1, 10, 11, 9].map((gateway, index) => ({ ...original, + id: ["a", "b", "c", "d"][index], gateway: "192.0.2." + gateway, + gatewayRef: { resource: "ipv4_route", id: "*r" + gateway }, + controlProbe: { ...original.controlProbe!, sourceAddress: "198.51.100." + [1, 11, 12, 13][index] }, + })) + const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile: configured }) as { currentCandidateId: string; ranking: Array<{ eligible: boolean; candidate: { id: string } }> } + assert.equal(analysis.currentCandidateId, "a") + assert.deepEqual(analysis.ranking.filter(row => row.eligible).map(row => row.candidate.id), ["a", "b", "c", "d"]) + const enrollment = await invokeMcpTool(principal, "mm_route_profile_save_plan", { + profileId, profile: configured, enabled: true, expiresAt: new Date(Date.now() + 600000).toISOString(), + }) as { planId: string } + await approvePlan(enrollment.planId, writer, true) + assert.equal((await execute(enrollment.planId)).state, "applied") + const readController = async () => (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0] + const failures = new Set() + let switchPlanId: string | undefined + for (const [index, alternate] of [12, 13, 13, 12, 13].entries()) { + // Drive the normal scheduled entry point without waiting a minute. + await pool.query("UPDATE mcp_routing_profiles SET state=state-'probedAt' WHERE id=$1", [profileId]) + controllerFailoverProbeSources.length = 0 + // The previously passing reserve must be rechecked, and its prior + // pass cannot authorize failover if the current check fails. + controllerFailoverReserveHealthy = index !== 2 + await tickRoutingProfiles() + const row = await readController() + assert.equal(row.enabled, true, String(row.state.pausedReason)) + assert.deepEqual(controllerFailoverProbeSources, [1, 11, alternate].map(last => "198.51.100." + last)) + failures.add(row.state.failureKey) + assert.equal(failures.size, index + 1, "Each counted failure must come from a distinct path check") + assert.equal(writes, initialWrites) + if (index < 4) { + assert.equal(row.state.failureCount, index + 1) + assert.equal(row.state.planId, undefined, "Earlier healthy reserve evidence cannot replace a fresh passing probe") + } else { + assert.equal(row.state.candidateId, "d") + switchPlanId = row.state.planId + } + } + assert.ok(switchPlanId) + assert.equal((await loadPlan(switchPlanId)).state, "awaiting-approval") + await tickRoutingProfiles() + assert.equal(writes, initialWrites) + await approvePlan(switchPlanId, writer, true) + await tickRoutingProfiles() + const operationId: string = (await readController()).state.operationId + assert.ok(operationId) + assert.equal(await waitForOperation(operationId), "applied") + await tickRoutingProfiles() + const completed = await readController() + assert.equal(completed.enabled, true, String(completed.state.pausedReason)) + assert.equal(completed.state.baseline.packet.candidateId, "d") + assert.equal(completed.state.baseline.packet.transition.operationId, operationId) + assert.equal(completed.state.failoverReserveCursor, undefined) + assert.equal(writes, initialWrites + 1) + const result = { accepted: true, stableRanking: ["a", "b", "c", "d"], selectedReserve: "d", probeCallsPerTick: 3, + distinctFailuresBeforePlan: failures.size, priorPassedProbeNotReused: true, humanApproval: true, nativeWrites: 1 } + console.log("Bounded controller reserve SQL/HTTP:", JSON.stringify(result)) + return result + } finally { await disablePersistent(profileId); controllerFailoverProbeSources = undefined; controllerFailoverReserveHealthy = true } + } + if (options.controllerFailoverOnly) return { ...await acceptControllerFailover(), persistentAuthorityCases: 0 } if (options.originalEffectsOnly) { const cases: Array> = [] let publicManualRevision2: Record | undefined @@ -546,8 +626,9 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: assert.equal(switched.state.baseline.packet.transition.planId, switchPlanId) assert.equal(writes - beforeControllerWrites, 1) await disablePersistent(activeProfileId) + const boundedReserveFailover = await acceptControllerFailover() assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore) - const result = { accepted: true, cases, publicManualRevision2, persistentAuthorityCases: 0, realMcpDispatcher: true, realSql: true, routerOsTransport: "declared-synthetic-localhost-http", + const result = { accepted: true, cases, publicManualRevision2, boundedReserveFailover, persistentAuthorityCases: 0, realMcpDispatcher: true, realSql: true, routerOsTransport: "declared-synthetic-localhost-http", genuineApprovedRunningOrigin: true, actualConfigurationCheckpoint: true, signedEffectTamperRejected: true, originalAutoRecoveryEnabled: false, fullRoutingAccepted: false, scopedControllerEnabled: true, publicScopedControllerLifecycle: true, nativeChrAccepted: false, forwardingVerified: false } console.log("Original effect SQL/runtime component:", JSON.stringify(result)) @@ -700,10 +781,20 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: const recovery = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: source.id }) as { planId: string; state: string } assert.equal(recovery.state, "awaiting-approval") assert.equal((await loadPlan(recovery.planId)).operation_id, "operations.recover") - assert.equal((await loadPlan(recovery.planId)).operation_version, 7) + assert.equal((await loadPlan(recovery.planId)).operation_version, 8) await assert.rejects(applyPlan(principal, recovery.planId, randomUUID())) assert.deepEqual(await snapshot(), afterForward, "No approval means no debit/charge/native write") await approvePlan(recovery.planId, writer, true) + if (chargeCase === "positive" && !options.lateInverseAuthorityChange) { + await pool.query("UPDATE mcp_plans SET operation_version=7 WHERE id=$1", [recovery.planId]) + try { + await assert.rejects(applyPlan(principal, recovery.planId, randomUUID()), { code: "PLAN_EXPIRED" }) + assert.equal((await pool.query("SELECT id FROM mcp_operations WHERE plan_id=$1", [recovery.planId])).rowCount, 0) + assert.deepEqual(await snapshot(), afterForward, "Superseded approved inverse cannot create an operation, debit or native write") + } finally { + await pool.query("UPDATE mcp_plans SET operation_version=8 WHERE id=$1", [recovery.planId]) + } + } if (chargeCase === "mixed-forward") { assert(competingForward) const attempts = [ @@ -827,9 +918,14 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: assert.equal(afterInverse.forward, persistent ? 1 : 0); assert.equal(afterInverse.manualInverse, persistent ? 1 : 0); assert.equal(afterInverse.total, persistent ? 2 : 0) assert.equal(afterInverse.reserved, 0); assert.equal(afterInverse.legacy, 0) assert.equal(afterInverse.writes, 2); if (chargeCase !== "unknown") assert.equal(inversePostChecks, 1) - const charges = (await pool.query("SELECT recovery_operation_id,recovery_plan_id,source_operation_id,profile_id,charge_units FROM mcp_routing_manual_inverse_charges WHERE source_operation_id=$1", [source.id])).rows + const charges = (await pool.query("SELECT recovery_operation_id,recovery_plan_id,source_operation_id,profile_id,charge_units,binding_sealed FROM mcp_routing_manual_inverse_charges WHERE source_operation_id=$1", [source.id])).rows assert.equal(charges.length, persistent ? 1 : 0); - if (persistent) { assert.equal(charges[0].recovery_operation_id, inverse.id); assert.equal(charges[0].recovery_plan_id, inverse.plan_id); assert.equal(charges[0].profile_id,id); assert.equal(charges[0].charge_units,1) } + if (persistent) { + assert.equal(charges[0].recovery_operation_id, inverse.id); assert.equal(charges[0].recovery_plan_id, inverse.plan_id); assert.equal(charges[0].profile_id,id); assert.equal(charges[0].charge_units,1) + const binding = JSON.parse(await decryptSecret(charges[0].binding_sealed)) + assert.equal(binding.operationVersion, 8) + assert.equal(binding.sourceVersion, 7) + } const intent = (await pool.query("SELECT state,verified_sealed,recovery_operation_id,authority_profile_id,authority_profile_version,reservation_id FROM mcp_scoped_recovery_intents WHERE source_operation_id=$1 AND source_step_index=0", [source.id])).rows assert.equal(intent.length, 1); assert.equal(intent[0].state, chargeCase === "unknown" ? "unknown" : "verified"); assert.equal(intent[0].recovery_operation_id, inverse.id) assert.equal(intent[0].authority_profile_id, null); assert.equal(intent[0].authority_profile_version, persistent ? 1 : null); assert.equal(intent[0].reservation_id, null) @@ -1235,9 +1331,12 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: console.log("Worker exclusion preserved exact running CONFIG_APPLIED profile") } const action = { principal, connection: await effectiveMcpConnection(principal) } + // Reuse the approved plan's original deadline: now +90s would extend + // its preparation-time probe horizon and mask the duplicate-owner guard. + const deadline = (await loadPlan(plan.planId)).expires_at.getTime() const reads = requests await assert.rejects(createRoutingNetworkNativeSession({ sourceOperationId: operationId, context: action, - authorize: async () => {}, deadline: Date.now() + 90000, manifest: { servers: [sid], probes: [] } }), /Source native session уже создавалась/) + authorize: async () => {}, deadline, manifest: { servers: [sid], probes: [] } }), /Source native session уже создавалась/) assert.equal(requests, reads); assert.equal(writes, before + 1) }) assert.equal(op.state, "applied"); assert.equal(writes, before + 1) diff --git a/backend/src/modules/mcp/routing-controls.ts b/backend/src/modules/mcp/routing-controls.ts index 82e339a6..ba4828ac 100644 --- a/backend/src/modules/mcp/routing-controls.ts +++ b/backend/src/modules/mcp/routing-controls.ts @@ -243,7 +243,7 @@ export function registerRoutingControls(register: typeof add) { }, }) register("routing.vrf.update", "mm_vrf_plan", "Проверенный состав существующего VRF", "network", "mm:network:write", "write", "servers", z.object({ serverId, id: objectId, interfaces: z.array(text).min(1).max(100).refine(values => new Set(values).size === values.length) }).strict(), { - version: 3, + version: 4, prepare: async (input, context) => { const sid = Number(input.serverId), { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, "vrf"), current = snapshot.rows.find(row => row[".id"] === input.id) if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения") diff --git a/backend/src/modules/mcp/routing-manual-inverse-history.test.ts b/backend/src/modules/mcp/routing-manual-inverse-history.test.ts index adfa6ee3..31d60cc1 100644 --- a/backend/src/modules/mcp/routing-manual-inverse-history.test.ts +++ b/backend/src/modules/mcp/routing-manual-inverse-history.test.ts @@ -57,7 +57,7 @@ function cursor(rows:unknown[],onFetch?:()=>void) { test("historical cursor authenticates more than two pages of genuine nonpersistent classifications",async()=>{ const dir=await mkdtemp(join(tmpdir(),"mm-manual-history-unit-")),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir try { - const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,n%2?6:7)).row) + const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,6+n%3)).row) const f=cursor(rows) await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{}) assert.equal(f.calls.filter(sql=>sql.startsWith("FETCH")).length,3) @@ -76,8 +76,10 @@ test("historical authentication, active persistent refusal, future rejection and await assertLegacyManualHistoryWithinOwner(cursor([f.row]).db,f.profileId,"2026-11-01T13:01:00.001Z",()=>{}) const expiredTamper={...f.row,source_evidence:null} await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{})) - const current={...f.row,recovery_version:7} - await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/) + for (const version of [7,8]) { + const current={...f.row,recovery_version:version} + await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/) + } const stripped={...f.input};delete stripped.persistentProfileAuthority await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([{...f.row,source_evidence:await encryptSecret(JSON.stringify({steps:[{kind:"service",service:"routing.community.scoped",input:stripped}]}))}]).db,f.profileId,at,()=>{})) const future={...f.intent,createdAt:"2026-11-01T12:00:00.001Z"} @@ -112,14 +114,14 @@ test("state transfer cap MODEL refuses oversized or NULL history as a whole, wit }finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})} }) -async function retentionFixture() { - const f=await fixture(true,7), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string} +async function retentionFixture(version = 7) { + const f=await fixture(true,version), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string} const payload=await encryptSecret(JSON.stringify({MODEL:true})) const row={...f.row,recovery_payload:payload,authority_profile_version:1} const binding=manualInverseReceiptSchema.shape.binding.parse({revision:1,recoveryOperationId:f.intent.operationId,recoveryPlanId:row.recovery_plan_id, sourceOperationId:f.intent.sourceOperationId,sourcePlanId:row.source_plan_id,sourceStepIndex:0,profileId:f.profileId,profileVersion:1, connectionId:f.intent.connectionId,ownerId:f.intent.ownerId,originalPrincipal:f.input.principal,recoveryPrincipal:f.input.principal, - operationVersion:7,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof, + operationVersion:version,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof, recoveryRequestProof:f.intent.requestSeal,sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence), recoveryPayloadProof:fingerprint(payload),scope:authority.packet.scope,createdAt:at,chargeUnits:1}) const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")} @@ -153,17 +155,19 @@ function retentionOwner(f:Awaited>,present=t test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown noReplay anchor survives',async()=>{ const dir=await mkdtemp(join(tmpdir(),'mm-intent2-retention-')),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir try { - const f=await retentionFixture(),owner=retentionOwner(f),before=fingerprint(f.row) - // SQL aging and the transaction owner below are MODELS, never genuine SQL. - await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{}) - assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1}) - assert.equal(owner.deleted.length,1);assert.equal(fingerprint(f.row),before) - for(const profile of [f.profileId,randomUUID()])await assertLegacyManualHistoryWithinOwner(owner.db,profile,'2026-11-03T12:00:00.000Z',()=>{}) - assert(owner.calls.findIndex(s=>s.includes('pg_advisory_xact_lock'))s.startsWith('SELECT c.recovery_operation_id'))) - const candidate=owner.calls.find(s=>s.startsWith('SELECT c.recovery_operation_id'))! - assert(candidate.indexOf("state IN('pending','running')"){}) + assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1}) + assert.equal(owner.deleted.length,1);assert.equal(fingerprint(f.row),before) + for(const profile of [f.profileId,randomUUID()])await assertLegacyManualHistoryWithinOwner(owner.db,profile,'2026-11-03T12:00:00.000Z',()=>{}) + assert(owner.calls.findIndex(s=>s.includes('pg_advisory_xact_lock'))s.startsWith('SELECT c.recovery_operation_id'))) + const candidate=owner.calls.find(s=>s.startsWith('SELECT c.recovery_operation_id'))! + assert(candidate.indexOf("state IN('pending','running')"){}),/lacks durable/) + const wrongVersion=await retentionFixture(7);wrongVersion.row.recovery_version=8 + const mismatched=retentionOwner(wrongVersion) + await assert.rejects(assertLegacyManualHistoryWithinOwner(mismatched.db,wrongVersion.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/signed allocation/) + await assert.rejects(retainManualInverseChargesWithinOwner(mismatched.db,1),/hint/) + assert.equal(mismatched.deleted.length,0) // Revision2 payload proof cannot be replaced by an authentic stamp from a different payload. const payload=await retentionFixture();payload.row.recovery_payload=await encryptSecret('{}') await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(payload,false).db,payload.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/payload/) diff --git a/backend/src/modules/mcp/routing-manual-inverse-history.ts b/backend/src/modules/mcp/routing-manual-inverse-history.ts index 5ea929e8..c6d7dc13 100644 --- a/backend/src/modules/mcp/routing-manual-inverse-history.ts +++ b/backend/src/modules/mcp/routing-manual-inverse-history.ts @@ -40,7 +40,7 @@ export async function classifyManualInverseHistory(row:HistoricalRow,observedAt: intent.receiptId!==row.recovery_receipt_id || intent.validUntil!==iso(row.deadline) || row.source_connection_id!==intent.connectionId || row.recovery_connection_id!==intent.connectionId || row.source_action!=="routing.community.switch" || row.source_version!==7 || - row.recovery_action!=="operations.recover" || ![6,7].includes(row.recovery_version??0) || + row.recovery_action!=="operations.recover" || ![6,7,8].includes(row.recovery_version??0) || !["partial","applied"].includes(row.source_state??"")) throw fail("Historical manual intent/source identity differs") const source=await sealedJson(row.source_evidence) as ChangeEvidence, recovery=await sealedJson(row.recovery_evidence) as ChangeEvidence const step=source.steps?.[0], inverse=recovery.steps?.[0] @@ -141,7 +141,7 @@ export async function assertLegacyManualHistoryWithinOwner(db:PoolClient,profile // Authenticate any durable inverse row before treating this old manual // identity as represented by the normal profile ledger. const {authenticateManualInverseHistoryWithinOwner}=await import("./routing-manual-inverse-store.js") - if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue + if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue if(row.recovery_version!==6) throw fail("Current persistent manual inverse lacks durable charge") if(classified.authority.packet.profileId!==profileId) continue // Historical v6 sealed evidence.validUntil bounded admission; source and diff --git a/backend/src/modules/mcp/routing-manual-inverse-store.ts b/backend/src/modules/mcp/routing-manual-inverse-store.ts index df180662..2f1ffe08 100644 --- a/backend/src/modules/mcp/routing-manual-inverse-store.ts +++ b/backend/src/modules/mcp/routing-manual-inverse-store.ts @@ -7,6 +7,7 @@ import { effectiveMcpConnection } from "./store.js" import { decryptSecretExisting, encryptSecret, fingerprint, McpError, privateFingerprinterExisting, privateSecretFingerprint } from "./security.js" import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js" import type { RoutingSwitchLedgerEntry } from "./routing-switch-ledger-contract.js" +import { findMcpOperation } from "./registry.js" const fail = (message: string) => new McpError("STATE_CONFLICT", message, 409) const uuid = z.string().uuid(), proof = z.string().regex(/^[a-f0-9]{64}$/), stamp = z.string().datetime() @@ -14,7 +15,7 @@ const principal = z.object({ connectionId: uuid, credentialId: uuid, credentialE export const manualInverseBindingSchema = z.object({ revision: z.literal(1), recoveryOperationId: uuid, recoveryPlanId: uuid, sourceOperationId: uuid, sourcePlanId: uuid, sourceStepIndex: z.literal(0), profileId: uuid, profileVersion: z.number().int().positive(), connectionId: uuid, ownerId: z.string().min(1).max(1000), originalPrincipal: principal, recoveryPrincipal: principal, - operationVersion: z.literal(7), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof, + operationVersion: z.union([z.literal(7), z.literal(8)]), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof, sourceRequestProof: proof, recoveryRequestProof: proof, sourceEvidenceProof: proof, recoveryEvidenceProof: proof, recoveryPayloadProof: proof, scope: z.array(z.number().int().positive().safe()).min(1).max(1000), createdAt: stamp, chargeUnits: z.literal(1) }).strict() @@ -74,7 +75,8 @@ export async function selectManualInverseAdmissionWithinOwner(db: PoolClient, pl if (plan.operation_id!=="operations.recover") return undefined const step=evidence.steps[0] if (evidence.steps.length!==1 || step.kind!=="service" || step.service!=="routing.community.scoped.recover") return undefined - if (plan.operation_version!==7 || step.input.mode!=="manual") throw fail("Manual recovery action version/mode differs") + if (plan.operation_version!==findMcpOperation(plan.operation_id)?.version) throw new McpError("PLAN_EXPIRED", "Версия операции изменилась; подготовьте новый план", 409) + if (step.input.mode!=="manual") throw fail("Manual recovery mode differs") const { loadScopedManualRecoveryAdmissionWithinOwner } = await import("./routing-community-scoped-recovery.js") const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db) const { source } = loaded @@ -92,7 +94,7 @@ function expectedBinding(selected: NonNullable,actor:McpPrincipal) { - const rows=(await db.query("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=7",[operationId,actor.connectionId])).rows + const rows=(await db.query("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=$3",[operationId,actor.connectionId,findMcpOperation("operations.recover")?.version])).rows if(rows.length!==1) throw fail("Manual native claim requires current running action version") const plan=rows[0], evidence=JSON.parse(await decryptSecretExisting(plan.evidence.sealed)) as ChangeEvidence const step=evidence.steps[0] @@ -154,7 +156,7 @@ export async function assertManualInverseChargeWithinOwner(db:PoolClient,operati export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient, intent:{revision?:number;manualInverseCharge?:ManualInverseReceipt;operationId:string;sourceOperationId:string;sourceReceiptId:string;sourceProof:string;requestSeal:string;connectionId:string;ownerId:string}, authority:z.infer,observedAt:string, - identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) { + identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;recoveryVersion:number|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) { const rows=(await db.query(`SELECT ${columns} FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 OR source_operation_id=$2 LIMIT 3`,[intent.operationId,intent.sourceOperationId])).rows const copy = intent.revision === 2 ? await authenticateCopy(intent.manualInverseCharge, observedAt) : undefined if (!rows.length && !copy) return false @@ -166,7 +168,8 @@ export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient, value.profileId!==authority.packet.profileId || value.profileVersion!==authority.packet.profileVersion || value.authorityProof!==authority.proof || value.connectionId!==intent.connectionId || value.ownerId!==intent.ownerId || value.sourceProof!==intent.sourceProof || value.recoveryRequestProof!==intent.requestSeal || value.sourceEvidenceProof!==identity.sourceEvidenceProof || - value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation") + value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId || + value.operationVersion!==identity.recoveryVersion || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation") // Only revision2 needs this extra one-row bounded projection; preserve the // existing cursor page payload ceiling and legacy protocol untouched. const payload = copy ? (await db.query<{sealed:string|null}>("SELECT CASE WHEN octet_length(payload::text)<=1048576 THEN payload->>'sealed' END AS sealed FROM mcp_plans WHERE id=$1 LIMIT 2",[identity.recoveryPlanId])).rows : [] @@ -201,11 +204,11 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete " WHERE r.mode='manual' AND r.source_operation_id=$1 AND r.source_step_index=0 AND r.recovery_operation_id=$2",[candidate.source_operation_id,candidate.recovery_operation_id])).rows if(rows.length!==1)throw fail("Retention intent/source pair disappeared") const row=rows[0],classified=await classifyManualInverseHistory(row,observedAt) - if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==7 || + if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==classified.intent.manualInverseCharge.binding.operationVersion || row.authority_profile_version!==candidate.profile_version || row.authority_profile_version!==classified.intent.manualInverseCharge.binding.profileVersion) throw fail("Retention hint is not an authenticated persistent intent2") if(!await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{ - sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) + sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) throw fail("Retention parent is not authenticated") const result=await db.query("DELETE FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 AND source_operation_id=$2",[candidate.recovery_operation_id,candidate.source_operation_id]) if(result.rowCount!==1)throw fail("Retention parent delete was not confirmed") diff --git a/backend/src/modules/mcp/routing-model.test.ts b/backend/src/modules/mcp/routing-model.test.ts index b1e7aafb..c0acd20e 100644 --- a/backend/src/modules/mcp/routing-model.test.ts +++ b/backend/src/modules/mcp/routing-model.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { test } from "node:test" import { routeAiProfileSchema, type RouteObservation } from "@mmapp/contracts/mcp-routing" -import { rankRoutingCandidates, decideRoutingSwitch, type ControllerState, type PathHealth } from "./routing-model.js" +import { rankRoutingCandidates, decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth } from "./routing-model.js" const now = Date.parse("2026-10-17T10:00:00Z"), at = new Date(now).toISOString() const profile = routeAiProfileSchema.parse({ name: "Test", serverId: 1, community: "65000:10", family: "ipv4", candidates: [ @@ -81,6 +81,26 @@ test("failover starts a fresh series after downtime or legacy state and ignores assert.equal(result.state.failureObservedAt, new Date(time).toISOString()) } }) +test("bounded failover probing keeps the highest eligible reserve and eventually visits every other eligible probe", () => { + const controlProbe = { targetInterface: "exit-a", expectedHops: [{ serverId: 3, interface: "exit-a" }] } + const configured = routeAiProfileSchema.parse({ ...profile, excludedCandidateIds: ["05"], + candidates: Array.from({ length: 100 }, (_, index) => ({ ...profile.candidates[0], id: String(index).padStart(2, "0"), + controlProbe: index === 4 ? undefined : controlProbe })) }) + const ranking = rankRoutingCandidates(configured, a, now) + const expected = ranking.filter(row => row.eligible && row.candidate.controlProbe && !["00", "01"].includes(row.candidate.id)).map(row => row.candidate.id) + let cursor: string | undefined + for (const alternate of expected) { + const probes = selectFailoverReserveProbes(ranking, "00", cursor) + assert.deepEqual(probes.candidateIds, ["01", alternate]) + assert.equal(probes.cursor, alternate) + cursor = probes.cursor + } + assert.deepEqual(selectFailoverReserveProbes(ranking, "00", cursor).candidateIds, ["01", "02"]) + assert.deepEqual(selectFailoverReserveProbes(ranking, "00", "05").candidateIds, ["01", "02"]) + const pinned = rankRoutingCandidates({ ...configured, pinnedCandidateId: "09" }, a, now) + assert.deepEqual(selectFailoverReserveProbes(pinned, "00"), { candidateIds: ["09"], cursor: undefined }) + assert.deepEqual(selectFailoverReserveProbes(pinned, "09"), { candidateIds: [], cursor: undefined }) +}) test("routing combines RTT, independent losses and bottleneck speed, never claims an observed path", () => { const ranked = rankRoutingCandidates(profile, a, now).find(row => row.candidate.id === "a")! assert.equal(ranked.pingMs, 20); assert.ok(Math.abs(ranked.lossPct! - 1.99) < 0.00001) diff --git a/backend/src/modules/mcp/routing-model.ts b/backend/src/modules/mcp/routing-model.ts index a6dccb84..0a646dca 100644 --- a/backend/src/modules/mcp/routing-model.ts +++ b/backend/src/modules/mcp/routing-model.ts @@ -52,6 +52,17 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string } export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string } + +export function selectFailoverReserveProbes(ranking: ReturnType, currentCandidateId: string, previous?: string) { + const standbys = ranking.filter(({ candidate, eligible }) => eligible && candidate.id !== currentCandidateId && candidate.controlProbe) + .map(({ candidate }) => candidate.id) + const [preferred, ...remaining] = standbys + if (remaining.length === 0) return { candidateIds: standbys, cursor: undefined } + const index = remaining.indexOf(previous ?? "") + const cursor = remaining[(index + 1) % remaining.length] + return { candidateIds: [preferred, cursor], cursor } +} + const pathHealthFreshnessSec = 90 export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) { const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) } diff --git a/backend/src/modules/mcp/routing-native-endpoint.ts b/backend/src/modules/mcp/routing-native-endpoint.ts new file mode 100644 index 00000000..aac1ac45 --- /dev/null +++ b/backend/src/modules/mcp/routing-native-endpoint.ts @@ -0,0 +1,28 @@ +import { isIP } from "node:net" + +export function parseRoutingEndpoint(value: unknown) { + if (typeof value !== "string" || !value.length || value.length > 256) throw Error("missing-or-unsupported-field") + const match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(value) + if (!match) throw Error("unsupported-endpoint") + const [, address, prefix, interfaceName, vrf] = match + const family = isIP(address) + if (!family) throw Error("unsupported-address") + if (prefix !== undefined && prefix !== String(family === 4 ? 32 : 128)) throw Error("unsupported-peer-range") + return { address, interface: interfaceName, vrf } +} + +export function matchesBgpSessionContext( + row: Record, + expected: { table: string; vrf: string }, + local: ReturnType, + remote: ReturnType, +) { + if (!Object.hasOwn(row, "routing-table") || row["routing-table"] !== expected.table) return false + if (Object.hasOwn(row, "vrf")) { + return row.vrf === expected.vrf && (local.vrf === undefined || local.vrf === expected.vrf) && + (remote.vrf === undefined || remote.vrf === expected.vrf) + } + // RouterOS may report the transport VRF only in the two runtime addresses. + // The installation table alone cannot identify it. + return local.vrf === expected.vrf && remote.vrf === expected.vrf +} diff --git a/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts b/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts index 4f95f0c1..333e3a90 100644 --- a/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts +++ b/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts @@ -16,6 +16,8 @@ assert(process.env.MCP_STATE_DIR && isAbsolute(process.env.MCP_STATE_DIR)) assert.equal(basename(resolve(process.env.MCP_STATE_DIR)), "state") assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-")) const originalEffects = process.env.MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS === "true" +const controllerFailoverOnly = process.env.MMAPP_OWN_ROUTING_CONTROLLER_FAILOVER_ONLY === "true" +assert(!controllerFailoverOnly || originalEffects, "Focused controller failover requires the owned original-effects fixture") assert(!(originalEffects && process.env.MMAPP_OWN_ROUTING_WORKER_REPRO === "true"), "Owned effect acceptance cannot mix with the worker repro profile") process.env.AUTH_REQUIRED = "true" process.env.AUTH_JWT_SECRET = "owned-routing-forward-rounds-acceptance-secret" @@ -35,7 +37,7 @@ try { console.log("Worker fixture repro:",await (await import("./routing-worker-fixture-isolation.sql-helper.js")).acceptRoutingWorkerFixtureIsolation(actor)) } const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor, - originalEffects ? { originalEffectsOnly: true } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true }) + originalEffects ? { originalEffectsOnly: true, controllerFailoverOnly } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true }) assert.equal(result.accepted, true) } finally { await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation() diff --git a/backend/src/modules/mcp/routing-network-native-parser.test.ts b/backend/src/modules/mcp/routing-network-native-parser.test.ts index 42dc22e9..78914499 100644 --- a/backend/src/modules/mcp/routing-network-native-parser.test.ts +++ b/backend/src/modules/mcp/routing-network-native-parser.test.ts @@ -154,6 +154,25 @@ for (const mutation of ["duplicate-config", "wrong-config-id", "unresolved-templ assert.equal(f.parser.parseBgp("peer1", raw).status, "unknown") }) +for (const ipv6 of [false, true]) test("BGP runtime context can come from both exact qualified " + (ipv6 ? "IPv6" : "IPv4") + " endpoints", () => { + const f = fixture(ipv6), raw = structuredClone(f.bgp) as unknown as RawMutation, session = raw.payload[0] + const local = session["local.address"], remote = session["remote.address"] + delete session.vrf + session["local.address"] = `${local}@main` + session["remote.address"] = `${remote}@main` + const observation = f.parser.parseBgp("peer1", raw) + assert.equal(observation.status, "observed", JSON.stringify(observation)) + assert.equal(observation.securityProof, false) + for (const mutation of [ + { "remote.address": remote }, { "remote.address": `${remote}@tenant` }, + { "remote.address": `${remote}@main@main` }, { "routing-table": "main" }, + { vrf: "tenant" }, { vrf: null }, + ]) { + assert.equal(f.parser.parseBgp("peer1", { ...raw, payload: [{ ...session, ...mutation }] }).status, "unknown") + } + assert.equal(f.parser.parseBgp("peer1", { ...raw, payload: [session, { ...session, ".id": "*B3" }] }).status, "unknown") +}) + test("native cached non-established BGP cannot invent a model FSM state from an undocumented field", () => { const f = fixture(), raw = structuredClone(f.bgp) as unknown as RawMutation raw.payload[0].established = "false" diff --git a/backend/src/modules/mcp/routing-network-native-parser.ts b/backend/src/modules/mcp/routing-network-native-parser.ts index 7f771286..edb38144 100644 --- a/backend/src/modules/mcp/routing-network-native-parser.ts +++ b/backend/src/modules/mcp/routing-network-native-parser.ts @@ -2,6 +2,7 @@ import { isIP } from "node:net" import { z } from "zod" import { canonicalBgpPolicyPrefix } from "../../services/bgp-community-policy-model.js" import { captureRoutingNetworkManifest, type RoutingNetworkRound } from "./routing-network-verifier-model.js" +import { matchesBgpSessionContext, parseRoutingEndpoint } from "./routing-native-endpoint.js" /** Pure decoded-response parsers, NOT source authentication or completeness * proof. Only the explicit RouterOS7.20–7.26 field subset below is recognized. @@ -32,11 +33,8 @@ function canonicalAddress(value: unknown): string { return canonicalBgpPolicyPrefix(text + "/" + (family === 4 ? 32 : 128)).split("/")[0] } function endpoint(value: unknown) { - const text = literal(value), match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(text) - if (!match) return fail("unsupported-endpoint") - const address = canonicalAddress(match[1]) - if (match[2] !== undefined && match[2] !== String(isIP(address) === 4 ? 32 : 128)) return fail("unsupported-peer-range") - return { address, interface: match[3], vrf: match[4] } + const parsed = parseRoutingEndpoint(value) + return { ...parsed, address: canonicalAddress(parsed.address) } } function decodedRows(payload: unknown, maximum = 1000, ids = true): Row[] { let nodes = 0 @@ -144,7 +142,7 @@ export function captureRoutingNetworkNativeParser(rawManifest: unknown) { if (instance !== wanted.instance) continue if (local.address !== wanted.localAddress || remote.address !== wanted.remoteAddress) continue if (integer(row["remote.as"], 4294967295) !== wanted.remoteAsn || !afi(row["remote.afi"]).includes(expectedAfi) || !afi(row["local.afi"]).includes(expectedAfi)) return unknown("bgp-session-asn-or-afi-drift") - if (row.vrf !== binding.vrf || row["routing-table"] !== binding.table || [local.vrf, remote.vrf].some(vrf => vrf !== undefined && vrf !== binding.vrf) || local.interface !== binding.local.interface || remote.interface !== binding.remote.interface) return unknown("bgp-session-context-drift") + if (!matchesBgpSessionContext(row, binding, local, remote) || local.interface !== binding.local.interface || remote.interface !== binding.remote.interface) return unknown("bgp-session-context-drift") relevant.push(row) } if (relevant.length !== 1) return unknown("missing-or-ambiguous-bgp-session") diff --git a/backend/src/modules/mcp/routing-original-effects-controller.test.mjs b/backend/src/modules/mcp/routing-original-effects-controller.test.mjs index 9e33f262..6107197c 100644 --- a/backend/src/modules/mcp/routing-original-effects-controller.test.mjs +++ b/backend/src/modules/mcp/routing-original-effects-controller.test.mjs @@ -7,7 +7,9 @@ const require=createRequire(new URL('../../../package.json',import.meta.url)),ts const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex'),sign=async(value,purpose)=>hash([purpose,value]); const code=ts.transpileModule(await readFile(new URL('./routing-controller-baseline.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; const effectCode=ts.transpileModule(await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,effectExports={}; -new Function('exports','require',effectCode)(effectExports,name=>{if(name==='node:net')return require('node:net');if(name==='zod')return{z};if(name==='./security.js')return{fingerprint:hash};if(name==='./routing-community-context-model.js')return{};throw Error(name);}); +const endpointCode=ts.transpileModule(await readFile(new URL('./routing-native-endpoint.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,endpointExports={}; +new Function('exports','require',endpointCode)(endpointExports,require); +new Function('exports','require',effectCode)(effectExports,name=>{if(name==='node:net')return require('node:net');if(name==='zod')return{z};if(name==='./security.js')return{fingerprint:hash};if(name==='./routing-community-context-model.js')return{};if(name==='./routing-native-endpoint.js')return endpointExports;throw Error(name);}); const preparedValue={status:'prepared',revision:1,serverId:1,routeSelectionScope:'table-afi-community-observation',candidateId:'b',family:'ipv4',table:'tenant',community:'65000:10',expectedGateway:'192.0.2.9',expectedImmediate:{address:'198.51.100.2',interface:'ether2'},binding:{connectionId:'*c',sessionId:'*s',instance:'default',table:'tenant',vrf:'main',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',remoteAsn:65001,afi:'ip'},bfd:{applicability:'not-configured',evidence:'explicit-use-bfd-false'},routes:[{nativeId:'*r',prefix:'203.0.113.0/24',originalGateway:'192.0.2.8'}],probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe',expectedHops:['198.51.100.20']}}; async function fixture(selection='absent',effectStatus='observed-expected',switched=true){ const principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},profileId=randomUUID(),planId=randomUUID(),operationId=randomUUID(),grant=new Date(Date.now()+60000).toISOString(); diff --git a/backend/src/modules/mcp/routing-original-effects.test.mjs b/backend/src/modules/mcp/routing-original-effects.test.mjs index 391b586b..39351b7d 100644 --- a/backend/src/modules/mcp/routing-original-effects.test.mjs +++ b/backend/src/modules/mcp/routing-original-effects.test.mjs @@ -6,9 +6,11 @@ import { isIP } from 'node:net'; import { randomUUID, createHash } from 'node:crypto'; const require=createRequire(new URL('../../../package.json',import.meta.url)), ts=require('typescript'),{z}=require('zod'); const source=await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'); -const exports={}; +const exports={}, endpointExports={}; +const endpointSource=await readFile(new URL('./routing-native-endpoint.ts',import.meta.url),'utf8'); +new Function('exports','require',ts.transpileModule(endpointSource,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(endpointExports,require); const gateway=value=>{const match=/^([^@%\s,]+)(?:%([^@%\s,]+))?(?:@([^@%\s,]+))?$/.exec(value);if(!match||!isIP(match[1]))throw Error();return {address:match[1],family:isIP(match[1]),interface:match[2]??null,resolverTable:match[3]??'main'};}; -const providers={ 'node:net':{isIP},'node:crypto':{randomUUID},zod:{z},'./routing-community-context-model.js':{routeGatewayContext:gateway},'./security.js':{fingerprint:value=>createHash('sha256').update(JSON.stringify(value)).digest('hex')}, +const providers={ 'node:net':{isIP},'node:crypto':{randomUUID},zod:{z},'./routing-community-context-model.js':{routeGatewayContext:gateway},'./routing-native-endpoint.js':endpointExports,'./security.js':{fingerprint:value=>createHash('sha256').update(JSON.stringify(value)).digest('hex')}, './path-verification.js':{inspectControlPing:rows=>{const row=rows.at(-1),sent=Number(row.sent),received=Number(row.received);return {confirmedFailure:sent===3&&received===0,ping:{measured:sent===3&&Number.isInteger(received)&&received>=0&&received<=3,loss:(sent-received)/sent*100}};}, inspectTrace:(rows,destination,expected)=>({verified:Array.isArray(rows)&&rows.at(-1)?.address===destination&&expected.every(x=>rows.some(r=>r.address===x))})}}; new Function('exports','require',ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(exports,name=>{assert.ok(providers[name],name);return providers[name];}); @@ -26,11 +28,33 @@ function fixture(){ } test('actual captured predicate seals BGP destinations separately from static resolver ID',()=>{const f=fixture();assert.equal(f.selection.status,'prepared');assert.equal(f.selection.routes[0].nativeId,'*r');assert.notEqual(f.selection.routes[0].nativeId,f.raw.candidate['.id']);assert.equal(f.selection.binding.sessionId,'*s');assert.equal('sessionKey'in f.selection.routes[0],false);}); test('two real effect captures can observe expected FIB/adjacency/probe while logcoverage and stronger claims stay false',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.logCoverage,'unknown');assert.equal(result.routeOriginVerified,false);assert.equal(result.recursiveChainVerified,false);assert.equal(result.forwardingVerified,false);assert.equal(result.autoRecoveryAllowed,false);assert.equal(result.rounds[0].relatedTopicRows,1);}); +function qualifyRuntimeSession(row) { + delete row.vrf; + row['local.address'] += '@main'; + row['remote.address'] += '@main'; +} +test('BGP session uses exact runtime endpoint qualifiers when VRF is absent and the installation table is separate',async()=>{ + const f=fixture(); + qualifyRuntimeSession(f.raw.sessions[0]); + const selection=f.prepare(); + assert.equal(selection.status,'prepared'); + assert.equal(selection.binding.vrf,'main'); + assert.equal(selection.binding.table,'tenant'); + const samples=[f.sample(),f.sample()]; + const report=await exports.inspectOriginalEffectRounds(selection,samples,f.baseline); + assert.equal(report.status,'observed-expected'); + assert.equal(report.routeOriginVerified,false); +}); for(const [name,change]of[ ['missing affected BGP route',f=>{f.raw.routes=[];}],['ambiguous prefix',f=>{f.raw.routes.push({...f.raw.routes[0],'.id':'*r2'});}], ['connected resolver is not affected BGP route',f=>{f.raw.routes[0].bgp='false';f.raw.routes[0].connect='true';}], ['unresolved inheritance',f=>{f.raw.connections[0].templates='template';}],['BFD not explicitly known',f=>{delete f.raw.connections[0]['use-bfd'];}], ['router-selected source retained as unsupported effect subset',f=>{delete f.raw.probe.source;}],['mixed session identity',f=>{f.raw.sessions.push({...f.raw.sessions[0],'.id':'*s2'});}], + ['implicit VRF without both endpoint qualifiers',f=>{qualifyRuntimeSession(f.raw.sessions[0]);f.raw.sessions[0]['local.address']='192.0.2.1';}], + ['qualified endpoints in the installation table',f=>{qualifyRuntimeSession(f.raw.sessions[0]);f.raw.sessions[0]['remote.address']='192.0.2.2@tenant';}], + ['contradictory explicit VRF',f=>{qualifyRuntimeSession(f.raw.sessions[0]);f.raw.sessions[0].vrf='tenant';}], + ['malformed peer ASN',f=>{f.raw.sessions[0]['remote.as']='0xfde9';}], + ['duplicate native ID even with unrelated tuple',f=>{f.raw.sessions.push({...f.raw.sessions[0],instance:'unrelated'});}], ['noncanonical AFI prefix',f=>{f.raw.routes[0]['dst-address']='2001:db8::/64';}] ])test(`prepared ambiguity/unsupported remains unknown: ${name}`,()=>{const f=fixture();change(f);assert.equal(f.prepare().status,'unknown');}); test('baseline binds saved route IDs/prefix/gateway and does not infer operation UUID',()=>{const f=fixture(),base=f.sample();base.servers[0].routes.payload=structuredClone(f.raw.routes);assert.equal(exports.originalEffectBaselineMatches(f.selection,base),true);base.servers[0].routes.payload[0]['.id']='*different';assert.equal(exports.originalEffectBaselineMatches(f.selection,base),false);}); diff --git a/backend/src/modules/mcp/routing-original-effects.ts b/backend/src/modules/mcp/routing-original-effects.ts index 3633a900..27f57fa4 100644 --- a/backend/src/modules/mcp/routing-original-effects.ts +++ b/backend/src/modules/mcp/routing-original-effects.ts @@ -2,6 +2,7 @@ import { isIP } from "node:net" import { z } from "zod" import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing" import { routeGatewayContext } from "./routing-community-context-model.js" +import { matchesBgpSessionContext, parseRoutingEndpoint } from "./routing-native-endpoint.js" import { fingerprint } from "./security.js" type Row = Record @@ -43,10 +44,27 @@ export type OriginalEffectReport = z.infer export const unknownOriginalEffects = (reason: string): OriginalEffectReport => ({ scope: "prepared-route-fib-adjacency-control-effects", status: "unknown", reason, rounds: [], logSource: "routeros-rest-ring", logCoverage: "unknown", selectionIdentity: null, routeSelectionScope: "table-afi-community-observation", transitionAttribution: "unproven", switchEffectObserved: false, semanticProof: false, routeOriginVerified: false, recursiveChainVerified: false, forwardingVerified: false, autoRecoveryAllowed: false }) -function tuple(row: Row, expected: Prepared["binding"], session: boolean) { +function connectionMatches(row: Row, expected: Prepared["binding"]) { return row.instance === expected.instance && row["routing-table"] === expected.table && row.vrf === expected.vrf && - row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress && Number(row["remote.as"]) === expected.remoteAsn && - (session ? row["local.afi"] === expected.afi && row["remote.afi"] === expected.afi : row.afi === expected.afi) + row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress && + (row["remote.as"] === expected.remoteAsn || row["remote.as"] === String(expected.remoteAsn)) && + row.afi === expected.afi +} + +function selectBgpSession(rows: Row[], expected: Prepared["binding"]) { + try { + let match: Row | undefined + for (const row of rows) { + if (row.instance !== expected.instance) continue + const local = parseRoutingEndpoint(row["local.address"]), remote = parseRoutingEndpoint(row["remote.address"]) + if (local.address !== expected.localAddress || remote.address !== expected.remoteAddress) continue + if (match || local.interface !== undefined || remote.interface !== undefined || !matchesBgpSessionContext(row, expected, local, remote) || + (row["remote.as"] !== expected.remoteAsn && row["remote.as"] !== String(expected.remoteAsn)) || row["local.afi"] !== expected.afi || row["remote.afi"] !== expected.afi || + row.state !== undefined || flag(row.established) === undefined || !id.safeParse(row[".id"]).success) return undefined + match = row + } + return match && unique(rows, String(match[".id"])).length === 1 ? match : undefined + } catch { return undefined } } function selectedRows(rows: Row[], selection: Pick) { return rows.filter(row => row["routing-table"] === selection.table && family(row) === selection.family && flag(row.bgp) === true && communities(row).includes(selection.community)) @@ -69,10 +87,10 @@ export function prepareOriginalEffectSelection(profile: RouteAiProfile, candidat const peer = connection[0], afi = profile.family === "ipv4" ? "ip" : "ipv6" const wanted = tupleSchema.parse({ connectionId: peer[".id"], sessionId: "*0", instance: binding.instance, table: binding.table, vrf: binding.vrf, localAddress: peer["local.address"], remoteAddress: peer["remote.address"], remoteAsn: Number(peer["remote.as"]), afi }) - if (!tuple(peer, wanted, false)) throw Error() - const sessions = raw.sessions.filter(row => tuple(row, wanted, true)) - if (sessions.length !== 1 || flag(sessions[0].established) === undefined) throw Error() - wanted.sessionId = id.parse(sessions[0][".id"]) + if (!connectionMatches(peer, wanted)) throw Error() + const session = selectBgpSession(raw.sessions, wanted) + if (!session) throw Error() + wanted.sessionId = id.parse(session[".id"]) const affected = selectedRows(raw.routes, profile) if (!affected.length || affected.length > 32 || new Set(affected.map(row => row["dst-address"])).size !== affected.length || new Set(affected.map(row => row[".id"])).size !== affected.length) throw Error() for (const row of affected) { @@ -130,10 +148,10 @@ export async function inspectOriginalEffectRounds(rawSelection: unknown, samples return { prefix: wanted.prefix, status } }) const connections = unique(source.bgp.connectionsPayload, selection.binding.connectionId) - const sessions = source.bgp.payload.filter(row => tuple(row, selection.binding, true)) + const session = selectBgpSession(source.bgp.payload, selection.binding) let bgp: z.infer = "unknown" - if (connections.length === 1 && tuple(connections[0], selection.binding, false) && flag(connections[0].disabled) === false && flag(connections[0]["use-bfd"]) === false && - sessions.length === 1 && sessions[0][".id"] === selection.binding.sessionId && flag(sessions[0].established) !== undefined) bgp = flag(sessions[0].established) ? "observed-expected" : "observed-other" + if (connections.length === 1 && connectionMatches(connections[0], selection.binding) && flag(connections[0].disabled) === false && flag(connections[0]["use-bfd"]) === false && + session?.[".id"] === selection.binding.sessionId) bgp = flag(session.established) ? "observed-expected" : "observed-other" const probe = sample.probes.find(row => row.key === selection.probe.key) let controlProbe: "passed" | "failed" | "unknown" = "unknown" if (probe) { diff --git a/backend/src/modules/mcp/routing-profiles.ts b/backend/src/modules/mcp/routing-profiles.ts index a46fe21d..e972b64e 100644 --- a/backend/src/modules/mcp/routing-profiles.ts +++ b/backend/src/modules/mcp/routing-profiles.ts @@ -9,7 +9,7 @@ import { pool } from "../../db/index.js" import { registerMcpService } from "./service-registry.js" import { accessGeneration, McpError } from "./security.js" import { audit, validatePrincipal, type McpPrincipal } from "./store.js" -import { decideRoutingSwitch, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js" +import { decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js" import type { add } from "./actions.js" import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js" import { controllerProfileCurrent, publishControllerState, saveControllerProfileCas, withControllerProfileLease } from "./routing-controller-fence.js" @@ -61,7 +61,7 @@ export function registerRoutingProfileActions(register: typeof add) { }) } -interface PersistentController extends ControllerState { baseline?:RoutingControllerBaseline; planId?: string; candidateId?: string; expectedCandidateId?: string; reason?: string; checkedAt?: string; probedAt?: string } +interface PersistentController extends ControllerState { baseline?:RoutingControllerBaseline; planId?: string; candidateId?: string; expectedCandidateId?: string; reason?: string; checkedAt?: string; probedAt?: string; failoverReserveCursor?: string } /** Also fenced per profile so direct/concurrent ticks cannot publish stale state. */ export async function tickRoutingProfiles() { const { invokeMcpTool, applyPlan } = await import("./runtime.js") @@ -95,6 +95,7 @@ export async function tickRoutingProfiles() { next.lastSwitchAt = at; next.switches = [...(state.switches ?? [])]; next.consecutive = 0 next.expectedCandidateId = state.candidateId delete next.planId; delete next.candidateId; delete next.pendingCandidateId; delete next.operationId + delete next.failoverReserveCursor next.reason = "Изменение конфигурации проверено; ожидаются новые измерения сети" } else if (plan.operation_id) next.reason = "Операция выполняется" else if (plan.expires_at.getTime() <= Date.now()) { await stop("План истёк без подтверждения"); return } @@ -111,26 +112,42 @@ export async function tickRoutingProfiles() { const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile }) as { ranking: ReturnType; currentCandidateId: string | null } if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return } if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId - const health: PathHealth[] = [] - const selected = [analysis.currentCandidateId, ...analysis.ranking.filter(value => value.eligible).slice(0, 2).map(value => value.candidate.id)] if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return } - for (const candidateId of [...new Set(selected)].filter((id): id is string => !!id)) { + const probeCandidate = async (candidateId: string): Promise => { + if (!profile.candidates.find(candidate => candidate.id === candidateId)?.controlProbe) return await current() - if (profile.candidates.find(candidate => candidate.id === candidateId)?.controlProbe) health.push(await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId }) as PathHealth) + return await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId }) as PathHealth + } + const currentHealth = analysis.currentCandidateId ? await probeCandidate(analysis.currentCandidateId) : undefined + const health = currentHealth ? [currentHealth] : [] + const failoverProbes = analysis.currentCandidateId && currentHealth?.status === "failed" + ? selectFailoverReserveProbes(analysis.ranking, analysis.currentCandidateId, state.failoverReserveCursor) : undefined + const candidates = failoverProbes?.candidateIds ?? analysis.ranking.filter(value => value.eligible).slice(0, 2) + .map(value => value.candidate.id).filter(id => id !== analysis.currentCandidateId) + for (const candidateId of candidates) { + const observed = await probeCandidate(candidateId) + if (observed) health.push(observed) } await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection}) const charges = await createRoutingReservationStore().readHourlyCharges(row.id, principal) await current() - const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, Date.now(), health, charges.totalCharges) - Object.assign(next, decision.state, { reason: decision.reason }) - next.probedAt = new Date().toISOString() - if (decision.state.pausedReason) { await stop(decision.state.pausedReason); return } - const hasFreshSelectedProbe = health.some(probe => { - if (probe.candidateId !== decision.switchTo || probe.status !== "passed") return false - const age = Date.now() - Date.parse(probe.observedAt) + const decisionAt = Date.now() + const hasFreshPassedProbe = (candidateId: string | null) => health.some(probe => { + if (probe.candidateId !== candidateId || probe.status !== "passed") return false + const age = decisionAt - Date.parse(probe.observedAt) return age >= 0 && age < COMMUNITY_PATH_MAX_AGE_MS }) - if (decision.switchTo && usesScopedRouteAiContext(profile) && !hasFreshSelectedProbe) { + const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, decisionAt, health, charges.totalCharges) + Object.assign(next, decision.state, { reason: decision.reason }) + if (!failoverProbes?.cursor) delete next.failoverReserveCursor + else if (!hasFreshPassedProbe(failoverProbes.cursor)) { + // Advance past failed/unknown reserves. Keep rechecking a passing + // reserve while the current path accumulates independent failures. + next.failoverReserveCursor = failoverProbes.cursor + } + next.probedAt = new Date().toISOString() + if (decision.state.pausedReason) { await stop(decision.state.pausedReason); return } + if (decision.switchTo && usesScopedRouteAiContext(profile) && !hasFreshPassedProbe(decision.switchTo)) { next.reason = "Ожидается успешная контрольная проба выбранного пути" } else if (decision.switchTo) { await current() diff --git a/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts b/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts index a7125ce0..277fe0c6 100644 --- a/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts +++ b/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts @@ -14,8 +14,10 @@ try { issuedAt: Date.now() - 1000, expiresAt: Date.now() + 900000 } await (await import("./store.js")).updateMcpSettings({ enabled: true }, actor) await import("./actions.js") - const mixed = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor, - { manualInverseChargeOnly: true, manualInverseChargeCase: "mixed-forward" }) + const { acceptScopedRouteCommunitySwitch } = await import("./routing-community-scoped-acceptance.js") + const inverse = await acceptScopedRouteCommunitySwitch(actor, { manualInverseChargeOnly: true }) + console.log("private routing v8 manual inverse charge and intent SQL acceptance:", JSON.stringify(inverse)) + const mixed = await acceptScopedRouteCommunitySwitch(actor, { manualInverseChargeOnly: true, manualInverseChargeCase: "mixed-forward" }) console.log("private routing mixed forward/manual inverse admission SQL acceptance:", JSON.stringify(mixed)) } finally { await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation() diff --git a/backend/src/modules/mcp/runtime.ts b/backend/src/modules/mcp/runtime.ts index 4aa5a35a..48628fa8 100644 --- a/backend/src/modules/mcp/runtime.ts +++ b/backend/src/modules/mcp/runtime.ts @@ -1,4 +1,6 @@ import { pollErrorLog } from "../../services/server-poll-error-provenance.js" +import { vrfPreparationDiagnostic } from "./vrf-preparation-diagnostic.js" +import { createVrfRuntimeDiagnosticBoundary } from "../../services/vrf-execution-diagnostic.js" import { capturePublishedServerProbeTerminalProof } from "../../services/server-probe-operation-retirement.js" import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js" import { preparedPlanExpiry } from "./plan-expiry.js" @@ -13,6 +15,7 @@ import { logSettingsPatchSchema } from "@mmapp/contracts" import { pool } from "../../db/index.js" import { hasPermission, type AuthUser } from "../../lib/permissions.js" import { createUser, updateUser, deleteUser, addBinding, removeBinding, getUserById } from "../users/service/users-service.js" +import { normalizeBindingPeer } from "../users/peer-bind.js" import { getLogSettings, updateLogSettings } from "../../services/router-logs-collector.js" import { refreshScheduler } from "../../services/scheduler.js" import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js" @@ -20,7 +23,7 @@ import { withOperationConfigurationLockClient, assertOperationConfigurationLockS import { sharedDatabaseGate } from "../../services/shared-database-gate.js" import { withRuntimePollPrincipalSource, executeRuntimePollPolicyTransaction, type RuntimePollPolicyLoan } from "./runtime-poll-principal-boundary.js" import { readPollRuntimePolicySource, type PollRuntimePolicySource } from "./poll-runtime-policy-source.js" -import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js" +import { withVerifiedConfigurationChange, assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js" import { beginMcpOperationAdmission, insertMcpOperationAdmission, retireMcpOperationAdmission, withMcpOperationExecutionOrigin, stripMcpOperationExecutionPrivateResult, type McpOperationAdmission } from "./operation-execution-origin.js" import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js" import { evaluateMcpPolicy } from "./policy.js" @@ -78,6 +81,12 @@ async function policyInput(client: Pick, connection: Connec usage: { callsInMinute: Number(usage.calls), mutationsInHour: Number(usage.mutations), concurrentReads: Number(usage.reads) }, auditAvailable: true, } } + +export async function readMcpPolicyPreviewInput(connection: ConnectionRow, input: Pick): Promise { + const current = await policyInput(pool, connection, input.operationId, input.target) + return { ...current, objects: input.objects, objectCount: input.objectCount } +} + async function temporaryScope(client: Pick, connection: ConnectionRow, credentialId: string) { const { rows } = await client.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [connection.id, credentialId]) if (!rows.length) return undefined @@ -127,6 +136,7 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw const call = await reserveCall(principal, toolName, input, signal) const cancelled = () => logsFollowMaintenance && logsFollowCancellation(logsFollowMaintenance, signal) const assertActive = () => { const failure = cancelled(); if (failure) throw failure } + let vrfPreparing = false try { assertActive() if (logsFollowMaintenance) call.context.logsFollowMaintenance = logsFollowMaintenance @@ -137,6 +147,7 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw let result: unknown if (action.read) result = await action.read(input, call.context) else if (action.prepare) { + vrfPreparing = ["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete"].includes(action.operation.id) const evidence = await action.prepare(input, call.context) result = await transaction(async client => { await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [principal.connectionId]) @@ -184,6 +195,7 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw await audit(client, connection.owner_id, "plan.create", connection.id, { planId: id, operationId: action.operation.id, decision }) return { planId: id, expiresAt: expiresAt.toISOString(), state: decision.decision === "approval-required" ? "awaiting-approval" : "draft", decision, changes: redact(publicMcpChangeEvidence(action.operation.id, evidence)), ...(vrfTicket ? { vrfTicket } : {}), executionAvailable: true } }) + vrfPreparing = false } else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409) assertActive() const current = await effectiveMcpConnection(principal) @@ -194,6 +206,12 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw assertActive() return output } catch (error) { + if (vrfPreparing) { + try { + const diagnostic = vrfPreparationDiagnostic(call.requestId, action.operation.id, error) + if (diagnostic) console.error(JSON.stringify(diagnostic)) + } catch { /* Diagnostics must never replace the original failure. */ } + } const failure = cancelled() ?? error await completeRequest(call.requestId, started, null, failure) throw failure @@ -262,7 +280,7 @@ async function serviceExecute(service: string, input: Record, c if (service === "users.create") return createUser(appUserCreateSchema.parse(input.user)) if (service === "users.update") return updateUser(String(input.id), appUserUpdateSchema.parse(input.user)) if (service === "users.delete") return deleteUser(String(input.id)) - if (service === "users.binding_add") return addBinding(String(input.id), userBindingCreateSchema.parse(input.binding)) + if (service === "users.binding_add") return addBinding(String(input.id), userBindingCreateSchema.parse(input.binding), { assertCurrent: assertVerifiedConfigurationAccess }) if (service === "users.binding_remove") return removeBinding(String(input.id), String(input.bindingId)) if (service === "logs.settings.update") { const result = await updateLogSettings(logSettingsPatchSchema.parse(input.settings)); await refreshScheduler(); return result } throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный сервис", 409) @@ -286,7 +304,11 @@ export async function verifyStep(step: ChangeStep, result: unknown, before?: Ros if (step.service === "users.binding_remove") { const current = await getUserById(String(step.input.id)); return !!current && !current.bindings.some(binding => binding.id === step.input.bindingId) } if (step.service === "users.binding_add") { const binding = userBindingCreateSchema.parse(step.input.binding), current = await getUserById(String(step.input.id)) - return !!current?.bindings.some(item => item.serverId === binding.serverId && item.interfaceName === binding.interfaceName && item.peerPublicKey === (binding.peerPublicKey ?? "")) + const createdId = result && typeof result === "object" ? (result as { id?: unknown }).id : undefined + return typeof createdId === "string" && !!current?.bindings.some(item => item.id === createdId + && item.serverId === binding.serverId && item.interfaceName === binding.interfaceName.trim() + && (binding.interfaceType === undefined || item.interfaceType === binding.interfaceType) + && item.peerPublicKey === normalizeBindingPeer(item.interfaceType, binding.peerPublicKey)) } if (step.service === "logs.settings.update") { const current = await getLogSettings() as unknown as Record @@ -441,6 +463,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin const plan = await loadPlan(operation.plan_id), evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence const serverIds = evidence.target.scope === "servers" ? evidence.target.serverIds : evidence.objects.flatMap(object => object.serverId ? [object.serverId] : []) let attempted = false, confirmed = 0, activeServiceStep = false + let vrfDiagnosticFallback: ReturnType | undefined let reservedForwardPassed = false let reservedRecoverySummary: Record | undefined const diagnosticOnly = evidence.steps.length === 1 && evidence.steps.every(step => step.kind === "service" && findMcpService(step.service)?.cancellableDiagnostic === true) @@ -548,17 +571,23 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin originalExpiresAt:Math.min(plan.expires_at.getTime(),evidence.validUntil?Date.parse(evidence.validUntil):Infinity,Date.parse(principal.credentialExpiresAt),Date.parse(connection.policy.expiresAt),connection.owner_valid_until.getTime(),originalServiceGrant!.admissionExpiresAt), serviceInput:step.input}:undefined const reservedStep: { outcome?: ReservedRoutingStepOutcome } = {} + vrfDiagnosticFallback = productionVrf && step.kind === "ros" && step.step.resource === "vrf" && ["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete", "routing.vrf.update"].includes(plan.operation_id) + ? createVrfRuntimeDiagnosticBoundary(operationId, step.step.action === "update" ? "patch" : step.step.action, diagnostic => { console.error(JSON.stringify(diagnostic)) }) : undefined const result = await withVerifiedConfigurationChange({ actorId: connection.owner_id, connectionId: connection.id, serverIds, objects: evidence.objects, approved: !!plan.approved_by, serverWide: step.kind === "service", ...(serviceExecution?{serviceExecution}:{}), authorize: async () => { await (step.kind === "ros" ? authorizeCatalog() : authorize()) }, ...(step.kind === "ros" ? { beforeDispatch: async () => { await prefix!.check(authorizeCatalog); await authorize(); await authorizeCatalog(); if (productionVrf) { if (!vrfBeforeDispatch) throw new McpError("STATE_CONFLICT", "VRF command guard отсутствует", 409); await vrfBeforeDispatch() } } } : {}) }, async () => { + vrfDiagnosticFallback?.executorEntered() if (step.kind === "ros") { if (!productionVrf) return executeRosStep(step.step) const before = evidence.snapshots.find(snapshot => snapshot.resource === "vrf" && snapshot.serverId === step.step.serverId) if (!before?.sourceProof) throw new McpError("STATE_CONFLICT", "Нет VRF source-v3 proof", 409) if (!["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete", "routing.vrf.update"].includes(plan.operation_id)) throw new McpError("STATE_CONFLICT", "VRF action binding изменён", 409) - return executeVrfProductionStep({ operationId, planId: plan.id, connectionId: connection.id, ownerId: connection.owner_id, + const receipt = await executeVrfProductionStep({ operationId, planId: plan.id, connectionId: connection.id, ownerId: connection.owner_id, operationName: plan.operation_id as "ros.vrf.create" | "ros.vrf.update" | "ros.vrf.delete" | "routing.vrf.update", operationVersion: plan.operation_version, stepIndex: index, step: step.step, sourceProof: before.sourceProof, originalSnapshots: evidence.snapshots, authorize: authorizeCatalog, - assertDependencies: () => prefix!.check(authorizeCatalog), installBeforeDispatch: guard => { vrfBeforeDispatch = guard } }) + assertDependencies: () => prefix!.check(authorizeCatalog), installBeforeDispatch: guard => { vrfBeforeDispatch = guard }, + onDiagnostic: diagnostic => { vrfDiagnosticFallback?.observed(diagnostic) } }) + vrfDiagnosticFallback?.executorReturned() + return receipt } const forward = () => serviceExecute(step.service, step.input, { operationId, phase: "forward", stepIndex: index, ...(routingReservationForward ? { routingReservationForward } : {}) }) if (!routingReservationId) return step.service === "servers.poll" ? withRuntimePollPrincipalSource(principal, async (current, policyLoan) => { @@ -571,6 +600,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin reservedStep.outcome = await executeReservedRoutingStep({ reservationId: routingReservationId, sourceOperationId: operationId, principal, forward }) return reservedStep.outcome.forwardResult }) + vrfDiagnosticFallback = undefined if (reservedStep.outcome?.kind === "recovered") { // The generic source remains intent/result=null until the private // inverse has finished. Record the forward ACK, never B VERIFIED @@ -636,6 +666,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin assertOperationConfigurationLockCheckpoint() if (receiptId && finishedAt) return { receiptId, operationId, connectionId:operation.connection_id, finishedAt } } catch (error) { + vrfDiagnosticFallback?.failure(error) if (routingReservationId && hasConfirmedReservedRoutingForward(error)) confirmed = Math.max(confirmed, 1) if (error instanceof VrfProductionAcknowledgedFailure) confirmed = Math.max(confirmed, error.acknowledgedWrites) const code = error instanceof VrfProductionClaimOutcomeUnknown ? "VRF_CLAIM_OUTCOME_UNKNOWN" : error instanceof McpError ? error.code : "DEVICE_UNAVAILABLE" diff --git a/backend/src/modules/mcp/users-bindings-owned.test.ts b/backend/src/modules/mcp/users-bindings-owned.test.ts new file mode 100644 index 00000000..6105c419 --- /dev/null +++ b/backend/src/modules/mcp/users-bindings-owned.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +process.env.AUTH_REQUIRED = "true" +process.env.AUTH_JWT_SECRET = "users-bindings-owned-acceptance-secret" +const { pool, closePool } = await import("../../db/index.js") +const { applySqlMigrations } = await import("../../db/migrate.js") +const { updateMcpSettings } = await import("./store.js") +const actor = { id: "users-bindings-owner", email: "users@example.invalid", name: "User binding owner", apps: ["mm"], permissions: ["mm:settings:admin"], expiresAt: Date.now() + 3600000 } +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 updateMcpSettings({ enabled: true }, actor) + await (await import("../../test/mcp-users-bindings.js")).verifyMcpUserBindings(actor) +} finally { + await closePool() +} diff --git a/backend/src/modules/mcp/vrf-preparation-diagnostic.test.ts b/backend/src/modules/mcp/vrf-preparation-diagnostic.test.ts new file mode 100644 index 00000000..9367e36e --- /dev/null +++ b/backend/src/modules/mcp/vrf-preparation-diagnostic.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { vrfPreparationDiagnostic } from "./vrf-preparation-diagnostic.js" + +const requestId = "12345678-1234-4123-8123-123456789abc" +const operationId = "ros.vrf.update" +function fixture(message = "VRF production transition refused: static address provenance/context", stack?: string) { + const error = new Error(message) + if (stack !== undefined) Object.defineProperty(error, "stack", { value: stack, configurable: true, writable: true }) + return error +} +const stack = "Error: SECRET-PASSWORD 192.0.2.9 native-body\n at must (C:\\private\\backend\\src\\services\\vrf-production-transition.ts:80:82)\n at wrapped (C:\\private\\backend\\src\\modules\\mcp\\vrf-ticket-runtime.ts:76:9)" + +test("plain Error at known refusal produces bounded first owned source, stage and constant reason", () => { + const diagnostic = vrfPreparationDiagnostic(requestId, operationId, fixture(undefined, stack)) + assert.deepEqual(diagnostic, { event: "mcp.vrf.preparation-failed", requestId, operationId, stage: "transition", errorClass: "Error", firstOwnedFrame: { source: "vrf-production-transition", line: 80 }, reason: "static-address-context" }) + for (const secret of ["SECRET-PASSWORD", "192.0.2.9", "private", "native-body", "must", "stack", "message"]) assert.ok(!JSON.stringify(diagnostic).includes(secret), secret) +}) +test("real constructed Error with native stack never returns function names or raw text", () => { + const diagnostic = vrfPreparationDiagnostic(requestId, "ros.vrf.create", new Error("SECRET native payload"))! + assert.equal(diagnostic.errorClass, "Error"); assert.equal(diagnostic.reason, "other") + assert.ok(!JSON.stringify(diagnostic).includes("SECRET")) + const known = new Error("VRF production transition refused: dependency connected route identity") + const result = vrfPreparationDiagnostic(requestId, operationId, known)! + assert.equal(result.reason, "connected-route-identity"); assert.equal(result.stage, "transition") + // Native lazy stack accessors stay unmaterialized. No prepareStackTrace hook + // is needed to classify the actual own-data refusal message. + assert.equal(typeof Object.getOwnPropertyDescriptor(known, "stack")?.get, "function") + assert.equal(result.firstOwnedFrame, undefined) +}) +test("exact known messages map to enums; appended secrets remain other", () => { + for (const [message, reason] of [ + ["VRF production ticket refused: original dependency projection changed during preparation", "original-dependency-projection"], + ["VRF production transition refused: dependency connected route identity", "connected-route-identity"], + ["VRF production transition refused: missing ordered main VRF", "main-vrf-context"], + ["VRF production transition refused: main VRF shape", "main-vrf-context"], + ]) { + assert.equal(vrfPreparationDiagnostic(requestId, operationId, fixture(message))!.reason, reason) + assert.equal(vrfPreparationDiagnostic(requestId, operationId, fixture(message + " SECRET"))!.reason, "other") + } +}) +test("actual native Error unknown refusal suffix maps only reviewed stage without exposing secrets", () => { + for (const [prefix, stage] of [["VRF production read refused: ", "read"], ["VRF production admission refused: ", "admission"], ["VRF production transition refused: ", "transition"], ["VRF production ticket refused: ", "ticket"]]) { + const error = new Error(prefix + "SECRET-PASSWORD 192.0.2.9 native-body") + Object.defineProperty(error, "name", { get() { throw Error("SECRET name getter invoked") } }) + const result = vrfPreparationDiagnostic(requestId, operationId, error)! + assert.equal(result.stage, stage); assert.equal(result.reason, "other"); assert.equal(result.firstOwnedFrame, undefined) + for (const secret of ["SECRET-PASSWORD", "192.0.2.9", "native-body", prefix]) assert.ok(!JSON.stringify(result).includes(secret)) + } + const tooLong = fixture("VRF production read refused: " + "S".repeat(512)) + assert.equal(vrfPreparationDiagnostic(requestId, operationId, tooLong)!.stage, "other") +}) +test("proxy, prototype and toStringTag spoofing reject without triggering traps", () => { + let traps = 0 + const fail = () => { traps++; throw Error("SECRET getter called") } + const proxy = new Proxy(fixture(), { get: fail, getPrototypeOf: fail, getOwnPropertyDescriptor: fail, ownKeys: fail }) + const spoof = Object.create(Error.prototype) as object + Object.defineProperties(spoof, { message: { get: fail }, stack: { get: fail }, [Symbol.toStringTag]: { get: fail } }) + for (const error of [proxy, spoof, { name: "Error", message: "SECRET", stack }]) assert.deepEqual(vrfPreparationDiagnostic(requestId, operationId, error), { event: "mcp.vrf.preparation-failed", requestId, operationId, stage: "other", errorClass: "NonError", reason: "other" }) + assert.equal(traps, 0) +}) +test("actual Error own accessors and constructor/name getters are never invoked", () => { + for (const field of ["message", "stack"]) { + const error = fixture(undefined, stack), calls = { count: 0 } + Object.defineProperty(error, field, { get() { calls.count++; throw Error("SECRET") } }) + Object.defineProperty(error, "name", { get() { calls.count++; throw Error("SECRET") } }) + Object.defineProperty(error, "constructor", { get() { calls.count++; throw Error("SECRET") } }) + const diagnostic = vrfPreparationDiagnostic(requestId, operationId, error)! + assert.equal(diagnostic.reason, field === "stack" ? "static-address-context" : "other"); assert.equal(diagnostic.stage, field === "stack" ? "transition" : "other"); assert.equal(diagnostic.firstOwnedFrame, undefined) + assert.equal(calls.count, 0) + } +}) +test("built-in error classes use prototype identity and subclasses remain a fixed enum", () => { + for (const [error, expected] of [[new TypeError("SECRET"), "TypeError"], [new RangeError("SECRET"), "RangeError"], [new SyntaxError("SECRET"), "SyntaxError"], [new (class SecretError extends Error {})("SECRET"), "OtherNativeError"]] as const) assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.errorClass, expected) +}) +test("nested Linux/Windows/compiled paths return source enums with bounded line only", () => { + for (const path of ["/app/dist/services/vrf-production-read.js", "file:///app/dist/services/vrf-production-read.js", "file:///C:/workspace/backend/src/services/vrf-production-read.ts", "C:\\workspace\\backend\\src\\services\\vrf-production-read.ts"]) { + const diagnostic = vrfPreparationDiagnostic(requestId, operationId, fixture("unexpected", `Error: SECRET\n at external (/tmp/node_modules/native.js:99:2)\n at closure (${path}:203:12)`))! + assert.equal(diagnostic.stage, "read"); assert.deepEqual(diagnostic.firstOwnedFrame, { source: "vrf-production-read", line: 203 }) + assert.ok(!JSON.stringify(diagnostic).includes(path)) + } +}) +test("non-owned paths, wrong directories and invalid line numbers never pass through", () => { + for (const path of ["/app/dist/services/private-secret.ts:1:1", "/app/dist/other/vrf-production-read.ts:1:1", "/app/dist/services/vrf-production-read.ts:0:1", "/app/dist/services/vrf-production-read.ts:1000001:1", "https://secret.example/src/services/vrf-production-read.ts:1:1"]) assert.equal(vrfPreparationDiagnostic(requestId, operationId, fixture("unexpected", `Error: SECRET\n at run (${path})`))!.firstOwnedFrame, undefined) +}) +test("stack scan has fixed byte/line bounds and never inspects error causes", () => { + const error = fixture("SECRET", "Error: SECRET\n" + " at external (/tmp/external.js:1:1)\n".repeat(70) + " at internal (/app/dist/services/vrf-production-transition.js:80:1)") + Object.defineProperty(error, "cause", { get() { throw Error("SECRET cause accessed") } }) + assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.firstOwnedFrame, undefined) + error.stack = "Error: " + "S".repeat(17000) + "\n at internal (/app/dist/services/vrf-production-transition.js:80:1)" + assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.firstOwnedFrame, undefined) +}) +test("nonerrors have no copied fields; non-VRF/non-UUID calls have no diagnostic", () => { + for (const error of [null, undefined, "SECRET", 42, Symbol("SECRET"), () => { throw Error("SECRET") }]) assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.errorClass, "NonError") + for (const operation of ["ros.vrf.list", "routing.vrf.update", "mcp.operation.apply", "ros.bridge.update", "ros.vrf.update SECRET"]) assert.equal(vrfPreparationDiagnostic(requestId, operation, fixture()), undefined) + assert.equal(vrfPreparationDiagnostic("SECRET-not-a-uuid", operationId, fixture()), undefined) +}) diff --git a/backend/src/modules/mcp/vrf-preparation-diagnostic.ts b/backend/src/modules/mcp/vrf-preparation-diagnostic.ts new file mode 100644 index 00000000..89cfaf29 --- /dev/null +++ b/backend/src/modules/mcp/vrf-preparation-diagnostic.ts @@ -0,0 +1,86 @@ +import { types } from "node:util" + +const ownedSources = { + "vrf-production-read": { directory: "services", stage: "read" }, + "vrf-production-admission": { directory: "services", stage: "admission" }, + "vrf-production-transition": { directory: "services", stage: "transition" }, + "vrf-production-ticket-store": { directory: "services", stage: "ticket" }, + "vrf-production-action-binding": { directory: "services", stage: "admission" }, + "vrf-members": { directory: "services", stage: "read" }, + "vrf-ticket-runtime": { directory: "modules/mcp", stage: "ticket" }, + "resources": { directory: "modules/mcp", stage: "read" }, + "actions": { directory: "modules/mcp", stage: "other" }, + "runtime": { directory: "modules/mcp", stage: "runtime" }, +} as const +type Source = keyof typeof ownedSources +type Stage = typeof ownedSources[Source]["stage"] +type Reason = "original-dependency-projection" | "static-address-context" | "connected-route-identity" | "main-vrf-context" | "other" +type ErrorClass = "Error" | "TypeError" | "RangeError" | "SyntaxError" | "ReferenceError" | "URIError" | "EvalError" | "OtherNativeError" | "NonError" +type Operation = "ros.vrf.create" | "ros.vrf.update" | "ros.vrf.delete" +export interface VrfPreparationDiagnostic { + event: "mcp.vrf.preparation-failed"; requestId: string; operationId: Operation; + stage: Stage; errorClass: ErrorClass; + firstOwnedFrame?: { source: Source; line: number }; + reason: Reason; +} + +// Only complete, reviewed constant refusals. No user/native text is copied and +// appending any text (including secrets) intentionally maps back to "other". +const reasons = new Map([ + ["VRF production ticket refused: original dependency projection changed during preparation", "original-dependency-projection"], + ["VRF production transition refused: static address provenance/context", "static-address-context"], + ["VRF production transition refused: dependency connected route identity", "connected-route-identity"], + ["VRF production transition refused: missing ordered main VRF", "main-vrf-context"], + ["VRF production transition refused: main VRF shape", "main-vrf-context"], +]) +const refusalStages: ReadonlyArray = [ + ["VRF production read refused: ", "read"], + ["VRF production admission refused: ", "admission"], + ["VRF production transition refused: ", "transition"], + ["VRF production ticket refused: ", "ticket"], +] +const classes: Array<[object, ErrorClass]> = [ + [Error.prototype, "Error"], [TypeError.prototype, "TypeError"], [RangeError.prototype, "RangeError"], [SyntaxError.prototype, "SyntaxError"], + [ReferenceError.prototype, "ReferenceError"], [URIError.prototype, "URIError"], [EvalError.prototype, "EvalError"], +] +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function dataString(error: object, key: "message" | "stack"): string | undefined { + const descriptor = Object.getOwnPropertyDescriptor(error, key) + return descriptor && Object.hasOwn(descriptor, "value") && typeof descriptor.value === "string" ? descriptor.value : undefined +} +function ownedFrame(stack: string | undefined): VrfPreparationDiagnostic["firstOwnedFrame"] { + if (!stack) return undefined + // Bound work before splitting, and ignore the first error-message line. + for (const line of stack.slice(0, 16384).split(/\r?\n/).slice(1, 65)) { + if (line.length > 2048 || !/^\s+at\s/.test(line)) continue + const match = /(?:^|[ (])(?:file:\/\/)?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?[\\/](?:src|dist)[\\/](services|modules[\\/]mcp)[\\/]([a-z-]+)\.(?:ts|js):([1-9]\d{0,6}):[1-9]\d{0,6}\)?$/.exec(line) + if (!match || !Object.hasOwn(ownedSources, match[2])) continue + const source = match[2] as Source, lineNumber = Number(match[3]) + if (match[1].replaceAll("\\", "/") !== ownedSources[source].directory || lineNumber > 1000000) continue + return { source, line: lineNumber } + } + return undefined +} + +/** Pure bounded diagnostic. Never inspect arbitrary error getters/proxies, + * retain original objects, or expose messages, paths, addresses or stack text. */ +export function vrfPreparationDiagnostic(requestId: string, operationId: string, error: unknown): VrfPreparationDiagnostic | undefined { + if (typeof requestId !== "string" || !uuid.test(requestId) || !["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete"].includes(operationId)) return undefined + const result: VrfPreparationDiagnostic = { event: "mcp.vrf.preparation-failed", requestId, operationId: operationId as Operation, stage: "other", errorClass: "NonError", reason: "other" } + // isNativeError is an internal-slot check, unlike instanceof/toString. It + // rejects Proxy(Error) and prototype/toStringTag imitations without traps. + if (!types.isNativeError(error)) return result + result.errorClass = classes.find(([prototype]) => Object.getPrototypeOf(error) === prototype)?.[1] ?? "OtherNativeError" + const message = dataString(error, "message"), stack = dataString(error, "stack") + if (message !== undefined && message.length <= 512) { + result.reason = reasons.get(message) ?? "other" + result.stage = refusalStages.find(([prefix]) => message.startsWith(prefix))?.[1] ?? "other" + } + // Node 22 uses a native lazy stack accessor. We deliberately do not invoke + // it (nor Error.prepareStackTrace); exact own-message reasons still work. + if (message === undefined || stack === undefined) return result + const frame = ownedFrame(stack) + if (frame) { result.firstOwnedFrame = frame; result.stage = ownedSources[frame.source].stage } + return result +} diff --git a/backend/src/modules/mcp/vrf-ticket-runtime.ts b/backend/src/modules/mcp/vrf-ticket-runtime.ts index d8a0e693..30516149 100644 --- a/backend/src/modules/mcp/vrf-ticket-runtime.ts +++ b/backend/src/modules/mcp/vrf-ticket-runtime.ts @@ -49,7 +49,7 @@ export async function prepareRuntimeVrfTicket(input: VrfRuntimeTicketInput, depe const actionId = vrfNativeActionFor(input.operationId, request) const context: PrepareVrfProductionTicketInput["context"] = { binding: { planId: input.planId, actorId: input.ownerId, serverId: native.serverId, sourceProof }, connectionId: input.connectionId, actionId, actionVersion: input.operationId === "routing.vrf.update" ? Number(findMcpOperation(actionId)?.version) : input.operationVersion, - ...(input.operationId === "routing.vrf.update" ? { authorityAction: { id: "routing.vrf.update" as const, version: input.operationVersion as 3 } } : {}) } + ...(input.operationId === "routing.vrf.update" ? { authorityAction: { id: "routing.vrf.update" as const, version: input.operationVersion as 4 } } : {}) } assertVrfActionContext(context, request); assertVrfAliasEvidence(context, request, evidence) const authority = async () => { await input.assertAuthority() diff --git a/backend/src/modules/users/binding-native-read.ts b/backend/src/modules/users/binding-native-read.ts new file mode 100644 index 00000000..224e3aa9 --- /dev/null +++ b/backend/src/modules/users/binding-native-read.ts @@ -0,0 +1,26 @@ +import type { UserBindingCreate } from "@mmapp/contracts/users" +import { getServerRowById } from "../servers/repository/servers-repository.js" +import { MikrotikClient } from "../../services/mikrotik.js" +import { sameWireguardEndpoint } from "../../services/wireguard-source.js" +import { ipsecClientBindingKeys, type IpsecServerState } from "../../services/ipsec-live.js" +import { bindingNativeRows, readBindingTarget, UserBindingTargetError } from "./binding-target.js" + +export function readNativeUserBindingTarget(input: UserBindingCreate, options: { signal?: AbortSignal; assertCurrent?: () => Promise } = {}) { + return readBindingTarget(input, async () => { + const server = await getServerRowById(input.serverId) + if (!server?.enabled) throw new UserBindingTargetError("Сервер отключён или не найден", 404) + const client = MikrotikClient.fromServer(server) + return { + async assertCurrent() { + const fresh = await getServerRowById(input.serverId) + if (!fresh || !sameWireguardEndpoint(fresh, server)) throw new UserBindingTargetError("Источник привязки изменился", 409) + }, + get: (path, timeout, signal, maximum) => client.get(path, timeout, signal, maximum), + ipsecKeys: (certs, peers, identities) => ipsecClientBindingKeys({ server, + certs: bindingNativeRows(certs, [".id", "name"]) as IpsecServerState["certs"], + peers: bindingNativeRows(peers, [".id"]) as IpsecServerState["peers"], + identities: bindingNativeRows(identities, [".id"]) as IpsecServerState["identities"], + }), + } + }, options) +} diff --git a/backend/src/modules/users/binding-target.test.ts b/backend/src/modules/users/binding-target.test.ts new file mode 100644 index 00000000..3645a1ee --- /dev/null +++ b/backend/src/modules/users/binding-target.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict" +import { resolveBindingInterface, resolveBindingPeer, readBindingTarget, UserBindingTargetError } from "./binding-target.js" + +const row = (name: string, type: string, id = "*1") => ({ ".id": id, name, type }) +const refused = (status: number) => (error: unknown) => error instanceof UserBindingTargetError && error.status === status +let cases = 0 +for (const [native, expected] of [["ether", "ether"], ["bridge", "bridge"], ["vlan", "vlan"], ["vxlan", "vxlan"], ["gre-tunnel", "gre"], ["wg", "wg"], ["wireguard", "wg"], ["dummy", "other"]]) { + const iface = resolveBindingInterface({ serverId: 1, interfaceName: "actual" }, [row("actual", native)]) + assert.equal(iface.type, expected); cases++ +} +for (const raw of [undefined, {}, [{ ".id": "*1", name: "ether-guess" }], [row("actual", "")], [row("actual", "ether", "*G1")], [row("actual", "ether"), row("duplicate-id", "ether")]]) { + assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "actual" }, raw), refused(502)); cases++ +} +assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "missing" }, []), refused(404)); cases++ +assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "actual" }, [row("actual", "ether"), row("actual", "ether", "*2")]), refused(409)); cases++ +assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "actual", interfaceType: "ether" }, [row("actual", "wg")]), refused(409)); cases++ +const wgInput = { serverId: 1, interfaceName: "wg-real", peerPublicKey: "actual-key" } +const wg = resolveBindingInterface(wgInput, [row("wg-real", "wg")]) +const peer = { ".id": "*2", interface: "wg-real", "public-key": "actual-key", "preshared-key": "PRIVATE_VALUE" } +assert.equal(resolveBindingPeer(wgInput, wg, [peer]).peerPublicKey, "actual-key"); cases++ +assert.equal(JSON.stringify(resolveBindingPeer(wgInput, wg, [peer])).includes("PRIVATE_VALUE"), false); cases++ +for (const peers of [[], [{ ...peer, interface: "foreign" }], [{ ...peer, "public-key": "foreign" }]]) { + assert.throws(() => resolveBindingPeer(wgInput, wg, peers), refused(404)); cases++ +} +assert.throws(() => resolveBindingPeer(wgInput, wg, [peer, { ...peer, ".id": "*3" }]), refused(409)); cases++ +assert.throws(() => resolveBindingPeer({ ...wgInput, peerPublicKey: "" }, wg, [peer]), refused(400)); cases++ +for (const key of ["client-cn", "managed-psk-name"]) { + const input = { serverId: 1, interfaceName: "ipsec-vpn", peerPublicKey: key } + const iface = resolveBindingInterface(input) + assert.equal(resolveBindingPeer(input, iface, undefined, ["client-cn", "managed-psk-name"]).peerPublicKey, key); cases++ + assert.throws(() => resolveBindingPeer(input, iface, undefined, [key, key]), refused(409)); cases++ + assert.throws(() => resolveBindingPeer(input, iface, undefined, []), refused(404)); cases++ +} +let current = true, authority = true, nativeCalls = 0 +const open = async () => ({ + async assertCurrent() { if (!current) throw new UserBindingTargetError("Источник привязки изменился", 409) }, + async get(path: string, timeout: number, signal: AbortSignal, maximum: number) { + assert(timeout > 0 && timeout <= 10000); assert.equal(maximum, 1048576); assert(signal instanceof AbortSignal) + nativeCalls++ + return path === "/interface" ? [row("wg-real", "wg")] : [peer] + }, + ipsecKeys() { return ["client-cn", "managed-psk-name"] }, +}) +const selected = await readBindingTarget(wgInput, open, { assertCurrent: async () => { assert(authority, "current authority") } }) +assert.equal(nativeCalls, 2); cases++ +current = false; await assert.rejects(selected.assertCurrent(), refused(409)); cases++ +current = true; authority = false; await assert.rejects(selected.assertCurrent(), /current authority/); cases++ +authority = true +await assert.rejects(readBindingTarget(wgInput, async () => ({ ...await open(), async get() { current = false; return [row("wg-real", "wg")] } })), refused(409)); cases++ +current = true +const cancelled = new AbortController(); cancelled.abort() +await assert.rejects(readBindingTarget(wgInput, open, { signal: cancelled.signal })); cases++ +const ipsecPaths: string[] = [] +const psk = await readBindingTarget({ serverId: 1, interfaceName: "ipsec-vpn", peerPublicKey: "managed-psk-name" }, async () => ({ + ...await open(), async get(path) { ipsecPaths.push(path); return [] }, +})) +assert.equal(psk.peerPublicKey, "managed-psk-name") +assert.deepEqual(ipsecPaths, ["/certificate", "/ip/ipsec/peer", "/ip/ipsec/identity"]); cases++ +await assert.rejects(readBindingTarget(wgInput, async () => ({ ...await open(), async get() { throw Error("PRIVATE_UPSTREAM_BODY") } })), error => + refused(502)(error) && !(error as Error).message.includes("PRIVATE_UPSTREAM_BODY")); cases++ +console.log("User binding native selectors, eight types, peer keys, scope/source/authority and privacy cases:", cases) diff --git a/backend/src/modules/users/binding-target.ts b/backend/src/modules/users/binding-target.ts new file mode 100644 index 00000000..a757901f --- /dev/null +++ b/backend/src/modules/users/binding-target.ts @@ -0,0 +1,90 @@ +import type { InterfaceType, UserBindingCreate } from "@mmapp/contracts/users" +import { mapRosInterfaceType } from "./iface-type.js" +import { normalizeBindingPeer, PeerBindError, peerDisplayName } from "./peer-bind.js" +import { nativeNetworkRows } from "../../services/network-observed-read.js" + +export class UserBindingTargetError extends Error { + constructor(message: string, readonly status: 400 | 404 | 409 | 502) { super(message) } +} +export interface BindingInterface { ifaceName: string; type: InterfaceType; nativeId?: string } +export interface BindingTarget extends BindingInterface { peerPublicKey: string; peerName: string } +export function bindingNativeRows(raw: unknown, required: string[]) { + try { + const rows = nativeNetworkRows(raw, required) + if (rows.some(row => !/^\*[a-f0-9]+$/i.test(row[".id"]!))) throw new Error("native ID") + return rows + } + catch { throw new UserBindingTargetError("Не подтверждён native-каталог привязки.", 502) } +} +export function resolveBindingInterface(input: UserBindingCreate, raw?: unknown): BindingInterface { + const ifaceName = input.interfaceName.trim() + if (!ifaceName) throw new UserBindingTargetError("Имя интерфейса обязательно", 400) + let type: InterfaceType, nativeId: string | undefined + if (ifaceName === "ipsec-vpn") type = "ipsec" + else { + const rows = bindingNativeRows(raw, [".id", "name", "type"]) + const matches = rows.filter(row => row.name === ifaceName) + if (!matches.length) throw new UserBindingTargetError("Интерфейс не найден", 404) + if (matches.length !== 1) throw new UserBindingTargetError("Неоднозначное имя интерфейса", 409) + type = mapRosInterfaceType(matches[0].type) + nativeId = matches[0][".id"] + if (type === "ipsec") throw new UserBindingTargetError("Для IPsec выберите каталог ipsec-vpn", 409) + } + if (input.interfaceType !== undefined && input.interfaceType !== type) throw new UserBindingTargetError("Тип интерфейса изменился или не соответствует устройству", 409) + return { ifaceName, type, ...(nativeId ? { nativeId } : {}) } +} +export function resolveBindingPeer(input: UserBindingCreate, iface: BindingInterface, rawPeers?: unknown, ipsecKeys?: string[]): BindingTarget { + let peerPublicKey: string + try { peerPublicKey = normalizeBindingPeer(iface.type, input.peerPublicKey) } + catch (error) { if (error instanceof PeerBindError) throw new UserBindingTargetError(error.message, 400); throw error } + if (iface.type === "wg") { + const matches = bindingNativeRows(rawPeers, [".id", "interface", "public-key"]).filter(row => + [iface.ifaceName, iface.nativeId].includes(row.interface) && row["public-key"] === peerPublicKey) + if (!matches.length) throw new UserBindingTargetError("Пир WireGuard не найден на выбранном интерфейсе", 404) + if (matches.length !== 1) throw new UserBindingTargetError("Неоднозначный пир WireGuard", 409) + } + if (iface.type === "ipsec") { + if (!Array.isArray(ipsecKeys) || ipsecKeys.length > 10000 || ipsecKeys.some(key => typeof key !== "string" || !key.trim())) throw new UserBindingTargetError("Не подтверждён native-каталог клиентов IPsec", 502) + const matches = ipsecKeys.filter(key => key === peerPublicKey) + if (!matches.length) throw new UserBindingTargetError("Клиент IPsec не найден", 404) + if (matches.length !== 1) throw new UserBindingTargetError("Неоднозначный ключ клиента IPsec", 409) + } + return { ...iface, peerPublicKey, peerName: iface.type === "wg" ? peerDisplayName({ publicKey: peerPublicKey, name: input.peerName }) : "" } +} + +export interface BindingReadSession { + assertCurrent(): Promise + get(path: string, timeoutMs: number, signal: AbortSignal, maxResponseBytes: number): Promise + ipsecKeys(certs: unknown, peers: unknown, identities: unknown): string[] +} +/** Fixed catalogues only; no UI snapshot/history, writes, retries or authority token. */ +export async function readBindingTarget(input: UserBindingCreate, open: () => Promise, options: { signal?: AbortSignal; assertCurrent?: () => Promise } = {}) { + const end = performance.now() + 10000, signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000) + const session = await open() + const assertCurrent = async () => { + signal.throwIfAborted() + if (performance.now() >= end) throw new UserBindingTargetError("Истёк срок проверки привязки", 502) + await options.assertCurrent?.() + await session.assertCurrent() + signal.throwIfAborted() + if (performance.now() >= end) throw new UserBindingTargetError("Истёк срок проверки привязки", 502) + } + const read = async (path: string) => { + await assertCurrent() + let value: unknown + try { value = await session.get(path, Math.max(1, Math.ceil(end - performance.now())), signal, 1048576) } + catch { throw new UserBindingTargetError("Native-каталог привязки недоступен", 502) } + await assertCurrent() + return value + } + const iface = resolveBindingInterface(input, input.interfaceName.trim() === "ipsec-vpn" ? undefined : await read("/interface")) + let rawPeers: unknown, ipsecKeys: string[] | undefined + if (iface.type === "wg") rawPeers = await read("/interface/wireguard/peers") + if (iface.type === "ipsec") { + const certs = await read("/certificate"), peers = await read("/ip/ipsec/peer"), identities = await read("/ip/ipsec/identity") + ipsecKeys = session.ipsecKeys(certs, peers, identities) + } + const target = resolveBindingPeer(input, iface, rawPeers, ipsecKeys) + await assertCurrent() + return { ...target, assertCurrent } +} diff --git a/backend/src/modules/users/service/users-service.ts b/backend/src/modules/users/service/users-service.ts index 1374b033..a5d2cc45 100644 --- a/backend/src/modules/users/service/users-service.ts +++ b/backend/src/modules/users/service/users-service.ts @@ -41,10 +41,10 @@ import { parseRawInterfaces, } from "../iface-type.js" import { - normalizeBindingPeer, - PeerBindError, peerDisplayName, } from "../peer-bind.js" +import { readNativeUserBindingTarget } from "../binding-native-read.js" +import { UserBindingTargetError } from "../binding-target.js" import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js" import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js" @@ -190,28 +190,22 @@ async function deleteUserImpl(id: string): Promise { await deleteUserRowById(id) } -export function addBinding(userId: string, input: UserBindingCreate): Promise { return executeGuardedApplicationWrite({ kind: "user", id: userId }, () => userMutationDependencies(userId, [input.serverId]), () => addBindingImpl(userId, input)) } -async function addBindingImpl(userId: string, input: UserBindingCreate): Promise { +type BindingReadOptions = { signal?: AbortSignal; assertCurrent?: () => Promise } +export function addBinding(userId: string, input: UserBindingCreate, options: BindingReadOptions = {}): Promise { return executeGuardedApplicationWrite({ kind: "user", id: userId }, () => userMutationDependencies(userId, [input.serverId]), () => addBindingImpl(userId, input, options)) } +/** Read-only validation shared by plan preparation and the guarded write. */ +export async function validateUserBindingCreate(userId: string, input: UserBindingCreate, options: BindingReadOptions = {}): Promise<{ ifaceName: string; type: InterfaceType; peerPublicKey: string; peerName: string; assertCurrent(): Promise }> { const user = await getUserRowById(userId) if (!user) throw new UsersServiceError("Пользователь не найден", 404) const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1) if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404) - const ifaceName = input.interfaceName.trim() - if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400) - const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName) - let peerPublicKey = "" + let target: Awaited> try { - peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey) + target = await readNativeUserBindingTarget(input, options) } catch (err) { - if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status) + if (err instanceof UserBindingTargetError) throw new UsersServiceError(err.message, err.status) throw err } - const peerName = type === "wg" - ? peerDisplayName({ - publicKey: peerPublicKey, - name: input.peerName, - }) - : "" + const { ifaceName, type, peerPublicKey, peerName, assertCurrent } = target const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey) if (taken) { throw new UsersServiceError( @@ -221,8 +215,14 @@ async function addBindingImpl(userId: string, input: UserBindingCreate): Promise 409, ) } + return { ifaceName, type, peerPublicKey, peerName, assertCurrent } +} + +async function addBindingImpl(userId: string, input: UserBindingCreate, options: BindingReadOptions): Promise { + const { ifaceName, type, peerPublicKey, peerName, assertCurrent } = await validateUserBindingCreate(userId, input, options) const now = new Date().toISOString() try { + await assertCurrent() const row = await createBindingRow({ id: randomUUID(), userId, @@ -237,6 +237,7 @@ async function addBindingImpl(userId: string, input: UserBindingCreate): Promise }) return await toBindingDto(row) } catch (err) { + if (err instanceof UserBindingTargetError) throw new UsersServiceError(err.message, err.status) if (isUniqueConstraintError(err)) { throw new UsersServiceError( type === "wg" @@ -258,13 +259,6 @@ async function removeBindingImpl(userId: string, bindingId: string): Promise { - const snap = await getLatestSnapshot(serverId) - const parsed = parseRawInterfaces(snap?.rawInterfaces) - const found = parsed.find((i) => i.name === ifaceName) - return found?.type ?? "other" -} - export async function getInterfaceCatalogState(serverId: number): Promise { const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1) if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404) diff --git a/backend/src/routes/mcp-oauth.ts b/backend/src/routes/mcp-oauth.ts index 23dd7b58..4370452c 100644 --- a/backend/src/routes/mcp-oauth.ts +++ b/backend/src/routes/mcp-oauth.ts @@ -11,6 +11,10 @@ import { mcpPublicUrl } from "./mcp-transport.js" import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js" const clientInput = z.object({ name: z.string().trim().min(1).max(100), redirectUris: z.array(z.string().url()).min(1).max(10) }).strict() +const consentInput = z.discriminatedUnion("approve", [ + z.object({ requestId: z.string().uuid(), approve: z.literal(true), connectionId: z.string().uuid() }).strict(), + z.object({ requestId: z.string().uuid(), approve: z.literal(false), connectionId: z.string().uuid().optional() }).strict(), +]) function safeRedirect(value: string): boolean { const url = new URL(value) return !url.username && !url.password && !url.hash && (url.protocol === "https:" || url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) @@ -134,21 +138,29 @@ export const mcpOauth: FastifyPluginAsync = async app => { return authorization }) admin.post("/api/mcp/oauth/consent", async request => { - const input = z.object({ requestId: z.string().uuid(), connectionId: z.string().uuid(), approve: z.boolean() }).strict().parse(request.body) + const input = consentInput.parse(request.body) return transaction(async db => { const { rows: [authorization] } = await db.query("SELECT r.* FROM mcp_oauth_requests r JOIN mcp_oauth_clients c ON c.id=r.client_id WHERE r.id=$1 AND r.expires_at>now() AND r.code_digest IS NULL AND r.consumed_at IS NULL AND c.enabled=true FOR UPDATE OF r", [input.requestId]) if (!authorization) throw new McpError("NOT_FOUND", "Запрос согласия устарел", 404) - const connection = await getConnection(input.connectionId, db) - if (connection.owner_id !== request.authUser!.id || connection.policy.status !== "active" || connection.generation !== await accessGeneration() || connection.owner_valid_until.getTime() <= Date.now()) throw new McpError("PERMISSION_DENIED", "Выберите своё действующее подключение с подтверждёнными правами владельца") const redirect = new URL(authorization.redirect_uri) if (authorization.state) redirect.searchParams.set("state", authorization.state) redirect.searchParams.set("iss", issuer) - if (!input.approve) { redirect.searchParams.set("error", "access_denied"); await db.query("UPDATE mcp_oauth_requests SET consumed_at=now() WHERE id=$1", [authorization.id]) } - else { + let connectionId: string | null = null + if (input.approve) { + const connection = await getConnection(input.connectionId, db) + const generation = await accessGeneration(), now = Date.now() + if (connection.owner_id !== request.authUser!.id || connection.policy.status !== "active" || connection.generation !== generation || + Date.parse(connection.policy.expiresAt) <= now || connection.owner_valid_until.getTime() <= now) { + throw new McpError("PERMISSION_DENIED", "Выберите своё действующее подключение с подтверждёнными правами владельца") + } + connectionId = connection.id const code = randomToken("code"); redirect.searchParams.set("code", code) - await db.query("UPDATE mcp_oauth_requests SET code_digest=$2,connection_id=$3 WHERE id=$1", [authorization.id, tokenDigest(code), connection.id]) + await db.query("UPDATE mcp_oauth_requests SET code_digest=$2,connection_id=$3 WHERE id=$1", [authorization.id, tokenDigest(code), connectionId]) + } else { + redirect.searchParams.set("error", "access_denied") + await db.query("UPDATE mcp_oauth_requests SET consumed_at=now() WHERE id=$1", [authorization.id]) } - await audit(db, request.authUser!.id, "oauth.consent", connection.id, { clientId: authorization.client_id, approved: input.approve }) + await audit(db, request.authUser!.id, "oauth.consent", connectionId, { clientId: authorization.client_id, approved: input.approve }) return { redirectUrl: redirect.href } }) }) diff --git a/backend/src/routes/mcp.test.ts b/backend/src/routes/mcp.test.ts index 31daabc8..a21f6e15 100644 --- a/backend/src/routes/mcp.test.ts +++ b/backend/src/routes/mcp.test.ts @@ -163,6 +163,23 @@ try { assert.ok(!response.body.includes("NEVER_")) assert.equal(artifactQueries, 2, "malformed artifact stops after bounded metadata before ciphertext, owner, source or native access") } finally { pool.query = originalQuery; pool.connect = originalConnect } + const profile = { + id: createBody.connectionId, connection_id: createBody.connectionId, version: 2, + config: { name: "Route AI test" }, enabled: true, grant_expires_at: "2099-01-01T00:00:00Z", updated_at: "2026-11-01T00:00:00Z", + state: { reason: "План ожидает подтверждения", candidateId: "wan-b", failureCount: 3, + baseline: { nativeRule: "NEVER_PRIVATE_BASELINE" }, failoverReserveCursor: "NEVER_PRIVATE_CURSOR" }, + } + pool.query = (async (sql: string, values: unknown[]) => { + assert.match(sql, /FROM mcp_routing_profiles p JOIN mcp_connections c/) + assert.deepEqual(values, [true, "human-admin"]) + return { rows: [profile] } + }) as typeof pool.query + try { + const listed = await app.inject({ url: "/api/mcp/routing-profiles", headers: admin }) + assert.equal(listed.statusCode, 200, listed.body) + assert.deepEqual(listed.json().profiles, [{ ...profile, state: { reason: profile.state.reason, candidateId: "wan-b", failureCount: 3 } }]) + assert.ok(!listed.body.includes("NEVER_PRIVATE_")) + } finally { pool.query = originalQuery } env.authRequired = false assert.equal((await app.inject({ url: "/api/mcp/catalog" })).statusCode, 503) assert.equal((await app.inject({ method: "POST", url: "/api/mcp/policy/preview", payload, headers: admin })).statusCode, 503) diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index 6191a85d..4c972aee 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -4,6 +4,7 @@ import { humanIpsecExistingCertificateExportMetadata, readHumanIpsecExistingCert import type { createIpsecExistingCertificateExportArtifactStore } from "../services/ipsec-existing-certificate-export-artifact.js" import type { FastifyPluginAsync } from "fastify" import { z } from "zod" +import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan" import { randomUUID, createHash } from "node:crypto" import { mcpCatalogResponseSchema, mcpConnectionCreateSchema, mcpPageQuerySchema, mcpPolicyPreviewRequestSchema, mcpPolicyPreviewResponseSchema, mcpSettingsPatchSchema, mcpTemporaryGrantSchema } from "@mmapp/contracts/mcp" import { mcpProtectionCreateRequestSchema } from "../modules/mcp/protection-requests.js" @@ -16,7 +17,7 @@ import { pool } from "../db/index.js" import { hasPermission, type AuthUser } from "../lib/permissions.js" import { accessGeneration, decryptSecret, encryptSecret, McpError, randomToken, redact, tokenDigest } from "../modules/mcp/security.js" import { createMcpConnection, listMcpConnections, getMcpSettings, updateMcpSettings, getConnection, assertConnectionVisible, rotateConnectionToken, setConnectionStatus, updateMcpConnection, transaction, audit } from "../modules/mcp/store.js" -import { approvePlan, loadPlan, publicPlan } from "../modules/mcp/runtime.js" +import { approvePlan, loadPlan, publicPlan, readMcpPolicyPreviewInput } from "../modules/mcp/runtime.js" import { applyPlan, invokeMcpTool } from "../modules/mcp/runtime.js" import { findOwnerPlanReplayTombstone } from "../modules/mcp/operation-replay-tombstones.js" import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing" @@ -28,6 +29,7 @@ import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js" import { sharedDatabaseGate } from "../services/shared-database-gate.js" import { readFile } from "node:fs/promises" import { checkMcpProtocol, type ProtocolProbe } from "../modules/mcp/readiness.js" +import { publicRoutingControllerState } from "../modules/mcp/routing-community-public.js" import { humanBackupDownloadMetadata, readHumanBackupDownloadArtifact } from "../modules/mcp/backup-download-artifact.js" import { humanDatabaseBackupMetadata, readHumanDatabaseBackupArtifact } from "../modules/mcp/database-backup-artifact.js" import { humanWireguardExportMetadata, readHumanWireguardExportArtifact } from "../modules/mcp/wireguard-export-artifact.js" @@ -38,6 +40,7 @@ import { readServerPollCapacity } from "../services/server-poll-capacity.js" import { connectProbeStorage } from "../services/server-probe-storage-connection.js" const uuid = z.string().uuid() +const selectedPolicyPreviewSchema = mcpPolicyPreviewRequestSchema.pick({ operationId: true, target: true, objects: true, objectCount: true }).extend({ connectionId: uuid }) const deviceResource = z.enum(["ethernet", "bridge", "bridge_port", "bridge_vlan", "vlan", "ipv4_address", "ipv6_address", "vrf"]) const devicePlanCommon = { connectionId: uuid, fields: z.record(z.string(), z.unknown()).default({}) } export const mcpDevicePlanBodySchema = z.discriminatedUnion("action", [ @@ -126,15 +129,25 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options }) app.post("/mcp/policy/preview", async (request, reply) => { - const parsed = mcpPolicyPreviewRequestSchema.safeParse(request.body) + const parsed = z.union([selectedPolicyPreviewSchema, mcpPolicyPreviewRequestSchema]).safeParse(request.body) if (!parsed.success) return reply.code(400).send({ error: "Некорректные параметры симуляции политики MCP", details: parsed.error.flatten() }) const now = new Date() + let input + if ("connectionId" in parsed.data) { + const connection = await getConnection(parsed.data.connectionId) + assertConnectionVisible(connection, actorOf(request.authUser)) + const [generation, settings, current] = await Promise.all([ + accessGeneration(), getMcpSettings(), readMcpPolicyPreviewInput(connection, parsed.data), + ]) + const inactive = !settings.enabled || connection.generation !== generation || connection.owner_valid_until <= now + input = inactive && current.policy.status === "active" ? { ...current, policy: { ...current.policy, status: "paused" as const } } : current + } else input = parsed.data return mcpPolicyPreviewResponseSchema.parse({ simulation: true, executionAvailable: false, evaluatedAt: now.toISOString(), - policyVersion: parsed.data.policy.version, - result: evaluateMcpPolicy(parsed.data, now), + policyVersion: input.policy.version, + result: evaluateMcpPolicy(input, now), }) }) @@ -183,6 +196,20 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options await audit(pool, actor.id, "device.web.plan", input.connectionId, { serverId: params.serverId, resource: params.resource, action: input.action, objectId: input.action === "create" ? null : input.id ?? null }) return ownerDelegate(async () => invokeMcpTool(principal, `mm_${params.resource}_${input.action}_plan`, mcpDevicePlanArguments(params.serverId, input))) }) + app.get("/mcp/devices/:serverId/bridge-vlan/configuration", async request => { + const { serverId } = z.object({ serverId: z.coerce.number().int().positive() }).parse(request.params) + const { connectionId } = z.object({ connectionId: uuid }).strict().parse(request.query) + const principal = await ownerPrincipal(connectionId, actorOf(request.authUser), "network.bridge-vlan.read") + return ownerDelegate(() => invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId })) + }) + app.post("/mcp/devices/:serverId/bridge-vlan/configuration/plan", async request => { + const { serverId } = z.object({ serverId: z.coerce.number().int().positive() }).parse(request.params) + const { connectionId, ...input } = bridgeVlanConfigurationRequestSchema.omit({ serverId: true }).extend({ connectionId: uuid }).strict().parse(request.body) + const actor = actorOf(request.authUser) + const principal = await ownerPrincipal(connectionId, actor, "network.bridge-vlan.configure") + await audit(pool, actor.id, "device.web.vlan-plan", connectionId, { serverId, bridgeId: input.bridgeId }) + return ownerDelegate(() => invokeMcpTool(principal, "mm_bridge_vlan_configuration_plan", { serverId, ...input })) + }) app.get("/mcp/settings", async () => getMcpSettings()) app.put("/mcp/settings", async request => updateMcpSettings(mcpSettingsPatchSchema.parse(request.body), actorOf(request.authUser))) app.post("/mcp/pause", async request => { await updateMcpSettings({ enabled: false }, actorOf(request.authUser)); return { ok: true } }) @@ -310,14 +337,15 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options permissions: actions.filter(action => connection.policy.scope.operationIds.includes(action.operation.id)).every(action => action.operation.requiredPermissions.every(permission => hasPermission(connection.owner_permissions, permission) && hasPermission(connection.policy.permissions, permission))), credentials: (await pool.query("SELECT 1 FROM mcp_credentials WHERE connection_id=$1 AND revoked_at IS NULL AND expires_at>now() AND kind IN ('bearer','access') LIMIT 1", [connection.id])).rows.length > 0, } - const protocolProbe: ProtocolProbe = Object.values(checks).every(Boolean) ? await checkMcpProtocol(connection.id, actorOf(request.authUser)) : { status: "skipped", reason: "PRECHECK_FAILED", elapsedMs: 0, credentialType: "temporary-no-operations", clientCredentialsVerified: false } + const protocolProbe: ProtocolProbe = Object.values(checks).every(Boolean) ? await checkMcpProtocol(connection.id, actorOf(request.authUser)) : { status: "skipped", reason: "PRECHECK_FAILED", elapsedMs: 0, credentialType: "temporary-no-operations", capabilitiesRead: "skipped", clientCredentialsVerified: false } const result = { ...checks, protocolHandshake: protocolProbe.status === "passed" } await audit(pool, actorOf(request.authUser).id, "connection.check", connection.id, { checks: result, protocolProbe }) return { ok: Object.values(result).every(Boolean), checks: result, protocolProbe, checkedAt: new Date().toISOString(), transport: "streamable-http", endpointPath: "/mcp" } }) app.get("/mcp/routing-profiles", async request => { const actor = actorOf(request.authUser) - return { profiles: (await pool.query("SELECT p.id,p.connection_id,p.version,p.config,p.state,p.enabled,p.grant_expires_at,p.updated_at FROM mcp_routing_profiles p JOIN mcp_connections c ON c.id=p.connection_id WHERE ($1::boolean OR c.owner_id=$2) ORDER BY p.updated_at DESC LIMIT 200", [hasPermission(actor.permissions, "mm:mcp:admin"), actor.id])).rows } + const { rows } = await pool.query("SELECT p.id,p.connection_id,p.version,p.config,p.state,p.enabled,p.grant_expires_at,p.updated_at FROM mcp_routing_profiles p JOIN mcp_connections c ON c.id=p.connection_id WHERE ($1::boolean OR c.owner_id=$2) ORDER BY p.updated_at DESC LIMIT 200", [hasPermission(actor.permissions, "mm:mcp:admin"), actor.id]) + return { profiles: rows.map(row => ({ ...row, state: publicRoutingControllerState(row.state) })) } }) app.post("/mcp/routing-profiles/:id/pause", async request => { const id = idOf(request.params), actor = actorOf(request.authUser) diff --git a/backend/src/routes/recursive-routes.test.ts b/backend/src/routes/recursive-routes.test.ts index 9718de35..39b2b708 100644 --- a/backend/src/routes/recursive-routes.test.ts +++ b/backend/src/routes/recursive-routes.test.ts @@ -97,6 +97,9 @@ assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().r propertyFailure=false rawPayload=[{".id":"one",gateway:"192.0.2.1"}] assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown") +rawPayload=[{".id":"one",gateway:"192.0.2.1",inactive:"true"}] +assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"inactive") +assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways?serverId=1"})).json().gateways[0].status,"down") for (const type of ["home-router", "exit-node"] as const) { server.enabled=false;server.type=type const counts={writes,rawReads,savedSnapshots} diff --git a/backend/src/routes/recursive-routes.ts b/backend/src/routes/recursive-routes.ts index 11442059..4bc1d04f 100644 --- a/backend/src/routes/recursive-routes.ts +++ b/backend/src/routes/recursive-routes.ts @@ -146,7 +146,7 @@ const recursiveDefaults: RecursiveRouteDependencies = { // Every ownership marker is a comment. Keep all commented rows, including // unsupported ones that must block an unsafe replacement of the managed set. return purpose === "gateways" - ? client.get("/ip/route?.proplist=.id,gateway,active") + ? client.get("/ip/route?.proplist=.id,gateway,active,inactive") : client.post("/ip/route/print", { ".query": ["comment"] }) }, readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }), diff --git a/backend/src/services/bridge-vlan-configuration.test.ts b/backend/src/services/bridge-vlan-configuration.test.ts new file mode 100644 index 00000000..f0c652ec --- /dev/null +++ b/backend/src/services/bridge-vlan-configuration.test.ts @@ -0,0 +1,332 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan" +import { buildBridgeVlanConfiguration, bridgeVlanCurrentDesired, bridgeVlanInverseDesired, parseBridgeVlanIds, type BridgeVlanDesired, type BridgeVlanState } from "./bridge-vlan-configuration.js" + +const cpu = { resource: "bridge", id: "*1" }, access = { resource: "ethernet", id: "*2" }, trunk = { resource: "ethernet", id: "*3" } +function fixture() { + const state: BridgeVlanState = { + bridges: [{ ".id": "*1", name: "br-users", disabled: "false", pvid: "1", "vlan-filtering": "false", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false" }], + interfaces: [ + { ...cpu, name: "br-users", disabled: "false" }, { ...access, name: "ether2", disabled: "false" }, { ...trunk, name: "ether3", disabled: "false" }, + { resource: "ethernet", id: "*4", name: "ether-mgmt", disabled: "false" }, + { resource: "vlan", id: "*5", name: "mgmt-vlan", disabled: "false" }, + ], + ports: [{ ".id": "*10", bridge: "br-users", interface: "ether2", pvid: "10", "ingress-filtering": "true", "frame-types": "admit-all", disabled: "false" }], + vlans: [], vlanInterfaces: [], + ipv4Addresses: [{ ".id": "*20", address: "192.0.2.1/24", interface: "ether-mgmt", disabled: "false" }], ipv6Addresses: [], + } + const desired: BridgeVlanDesired = { + filtering: true, pvid: 1, + ports: [{ id: "*10", interface: access, pvid: 10, ingressFiltering: true, frameTypes: "admit-all", disabled: false }, { interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: "admit-only-vlan-tagged", disabled: false }], + vlans: [{ vlanIds: "10", tagged: [cpu, trunk], untagged: [access], disabled: false }], removePortIds: [], removeVlanIds: [], + } + return structuredClone({ serverId: 1, bridgeId: "*1", state, desired, management: { serverId: 1, channelId: "verified-channel", host: "192.0.2.1", expiresAt: "2026-11-05T01:00:00Z" }, now: Date.parse("2026-11-05T00:00:00Z") }) +} +const build = (change?: (value: ReturnType) => void) => { const value = fixture(); change?.(value); return buildBridgeVlanConfiguration(value) } + +test("enable creates VLAN and port before filtering, stable IDs and foreign rows retained", () => { + const value = fixture(); value.state.ports.push({ ".id": "*99", bridge: "foreign", interface: "foreign-port" }) + const original = structuredClone(value), plan = buildBridgeVlanConfiguration(value) + assert.deepEqual(plan.steps.map(step => [step.resource, step.action]), [["bridge_vlan", "create"], ["bridge_port", "create"], ["bridge", "update"]]) + assert.deepEqual(plan.steps.at(-1)?.fields, { "vlan-filtering": "true" }) + assert.equal(plan.steps[0].fields.tagged, "br-users,ether3") + assert.ok(plan.objects.some(object => object.kind === "bridge_port" && object.id === "*10")) + assert.deepEqual(value, original, "preparation must be pure") +}) +test("strict VID parsing includes arbitrary ranges and rejects overlaps and invalid syntax", () => { + assert.deepEqual(parseBridgeVlanIds("4094,11-13,1"), [1, 11, 12, 13, 4094]) + for (const bad of ["", "0", "4095", "10-9", "1,1", "1-3,3-4", "01", "1, 2", "1-", "+1", "1.1", "1,,2"]) assert.throws(() => parseBridgeVlanIds(bad), undefined, bad) +}) +test("public schema rejects management evidence, unknown flags, and fractional PVID", () => { + const value = fixture(), request = { serverId: value.serverId, bridgeId: value.bridgeId, desired: value.desired } + assert.ok(bridgeVlanConfigurationRequestSchema.safeParse(request).success) + assert.ok(!bridgeVlanConfigurationRequestSchema.safeParse({ ...request, management: value.management }).success) + assert.ok(!bridgeVlanConfigurationRequestSchema.safeParse({ ...request, desired: { ...request.desired, managementSafe: true } }).success) + assert.ok(!bridgeVlanConfigurationRequestSchema.safeParse({ ...request, desired: { ...request.desired, pvid: 1.5 } }).success) +}) +test("full desired set cannot silently omit existing rows or claim a foreign row", () => { + assert.throws(() => build(value => { value.desired.ports.shift() }), /полный список/) + assert.throws(() => build(value => { value.desired.ports[0].id = "*99" }), /полный список/) + assert.throws(() => build(value => { value.desired.removePortIds.push("*10") }), /Повтор/) +}) +test("cross-bridge memberships and foreign slaves are refused", () => { + assert.throws(() => build(value => { value.desired.vlans[0].tagged.push({ resource: "ethernet", id: "*4" }) }), /не принадлежит/) + assert.throws(() => build(value => { value.state.ports.push({ ".id": "*98", interface: "ether3", bridge: "foreign" }) }), /другому bridge/) + assert.throws(() => build(value => { value.desired.ports[1].interface = cpu }), /является bridge/) +}) +test("effective tagged/untagged overlap and duplicate membership are refused", () => { + assert.throws(() => build(value => { value.desired.vlans[0].tagged.push(access) }), /Повтор/) + assert.throws(() => build(value => { value.desired.vlans.push(structuredClone(value.desired.vlans[0])) }), /Неоднозначная/) +}) +test("explicit untagged VLAN must agree with PVID; omitted untagged uses native PVID membership", () => { + assert.throws(() => build(value => { value.desired.ports[0].pvid = 11 }), /не соответствует PVID/) + assert.ok(build(value => { value.desired.vlans[0].untagged = [] }).steps.length > 0) + assert.throws(() => build(value => { value.desired.vlans[0].vlanIds = "10-11" }), /не соответствует PVID/) +}) + +function implicitPvidManagement() { + const value = fixture() + value.state.bridges[0]["vlan-filtering"] = "true"; value.state.bridges[0].pvid = "10" + value.state.ipv4Addresses[0].interface = "br-users" + value.state.vlans = [{ ".id": "*30", bridge: "br-users", dynamic: "true", "vlan-ids": "10", "current-untagged": "br-users,ether2" }] + value.desired = bridgeVlanCurrentDesired(value.state, value.bridgeId) + return value +} +test("implicit PVID CPU and access membership supports a safe edit without static VLAN commands", () => { + const value = implicitPvidManagement(); value.desired.ports[0].ingressFiltering = false + const plan = buildBridgeVlanConfiguration(value) + assert.deepEqual(plan.steps, [{ serverId: 1, resource: "bridge_port", action: "update", id: "*10", fields: { "ingress-filtering": "false" } }]) + assert.equal(plan.managementPath.bridgeName, "br-users") + assert.deepEqual(plan.inverse.desired.vlans, []) +}) +test("implicit management rejects CPU/port PVID migration and CPU frame admission loss", () => { + for (const change of [ + (value: ReturnType) => { value.desired.pvid = 11 }, + (value: ReturnType) => { value.desired.ports[0].pvid = 11 }, + (value: ReturnType) => { value.desired.frameTypes = "admit-only-vlan-tagged" }, + ]) { + const value = implicitPvidManagement(); change(value) + assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management|management/) + } +}) +test("CPU observation is required and MVRP dynamic registration is unsupported", () => { + for (const field of ["frame-types", "ingress-filtering"]) { + const value = implicitPvidManagement(); delete value.state.bridges[0][field] + assert.throws(() => buildBridgeVlanConfiguration(value)) + } + const value = implicitPvidManagement(); value.state.bridges[0].mvrp = "true" + assert.throws(() => buildBridgeVlanConfiguration(value), error => error instanceof Error && "code" in error && error.code === "UNSUPPORTED_CAPABILITY") +}) +test("tagged-only CPU does not acquire implicit PVID membership and untagged-only CPU cannot serve a VLAN interface", () => { + const value = fixture(); value.state.bridges[0]["frame-types"] = "admit-only-vlan-tagged"; value.desired.pvid = 10 + assert.ok(buildBridgeVlanConfiguration(value).steps.length > 0) + const tagged = implicitPvidManagement() + tagged.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users,ether2", disabled: "false" }] + tagged.state.ports[0]["frame-types"] = "admit-only-vlan-tagged" + tagged.state.bridges[0]["frame-types"] = "admit-only-untagged-and-priority-tagged"; tagged.state.bridges[0].pvid = "1" + tagged.state.vlanInterfaces = [{ ".id": "*5", name: "mgmt-vlan", interface: "br-users", "vlan-id": "10", disabled: "false" }] + tagged.state.ipv4Addresses[0].interface = "mgmt-vlan" + tagged.desired = bridgeVlanCurrentDesired(tagged.state, tagged.bridgeId); tagged.desired.ports[0].ingressFiltering = false + assert.throws(() => buildBridgeVlanConfiguration(tagged), /CPU management/) +}) +test("multi-VLAN tagged ranges are supported without fabricated access ports", () => { + const plan = build(value => { value.desired.vlans.push({ vlanIds: "20-30,40", tagged: [cpu, trunk], untagged: [], disabled: false }) }) + assert.equal(plan.steps.filter(step => step.resource === "bridge_vlan" && step.action === "create").length, 2) +}) +test("independent management disables filtering first for edits and restores last", () => { + const plan = build(value => { value.state.bridges[0]["vlan-filtering"] = "true" }) + assert.deepEqual(plan.steps[0].fields, { "vlan-filtering": "false" }) + assert.deepEqual(plan.steps.at(-1)?.fields, { "vlan-filtering": "true" }) +}) +test("explicit deletion is supported, inverse recreates old configuration with original fields", () => { + const value = fixture() + value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users", untagged: "ether2", disabled: "false", comment: "original" }] + value.desired.filtering = false; value.desired.ports = []; value.desired.vlans = [] + value.desired.removePortIds = ["*10"]; value.desired.removeVlanIds = ["*11"] + const plan = buildBridgeVlanConfiguration(value) + assert.deepEqual(plan.steps.map(step => [step.resource, step.action, step.id]), [["bridge_vlan", "delete", "*11"], ["bridge_port", "delete", "*10"]]) + const inverse = bridgeVlanInverseDesired(plan, []) + assert.equal(inverse.ports[0].id, undefined); assert.equal(inverse.vlans[0].id, undefined) + assert.equal(inverse.vlans[0].comment, "original"); assert.equal(inverse.ports[0].pvid, 10) + assert.ok(plan.objects.some(object => object.kind === "ethernet" && object.id === access.id), "Removed old physical interface remains protected") + assert.ok(plan.objects.some(object => object.kind === "bridge" && object.id === cpu.id), "Removed old CPU membership remains protected") +}) +test("inverse removes only exact executor ACK IDs, never predicted native IDs", () => { + const plan = build(), inverse = bridgeVlanInverseDesired(plan, [{ stepIndex: 0, id: "*A1" }, { stepIndex: 1, id: "*A2" }]) + assert.deepEqual(inverse.removeVlanIds, ["*A1"]); assert.deepEqual(inverse.removePortIds, ["*A2"]) + assert.throws(() => bridgeVlanInverseDesired(plan, []), /ACK/) + assert.throws(() => bridgeVlanInverseDesired(plan, [{ stepIndex: 0, id: "*A1" }, { stepIndex: 0, id: "*A2" }]), /Повтор/) +}) +test("dynamic rows cannot be modified or deleted and never become commands", () => { + const plan = build(value => { value.state.vlans.push({ ".id": "*30", bridge: "br-users", dynamic: "true", "vlan-ids": "10", "current-untagged": "ether2" }) }) + assert.ok(plan.steps.every(step => step.id !== "*30")) + assert.throws(() => build(value => { value.state.ports[0].dynamic = "true" }), /полный список/) +}) +test("channel host must be an actual enabled configured address with live same-server receipt", () => { + assert.throws(() => build(value => { value.management.host = "198.51.100.1" }), /Адрес канала/) + assert.throws(() => build(value => { value.management.serverId = 2 }), /канала управления/) + assert.throws(() => build(value => { value.management.expiresAt = new Date(value.now).toISOString() }), /канала управления/) + assert.throws(() => build(value => { value.state.ipv4Addresses[0].disabled = "true" }), /Адрес канала/) + assert.throws(() => build(value => { value.state.ipv4Addresses.push({ ...value.state.ipv4Addresses[0], ".id": "*21" }) }), /однозначно/) +}) +test("equivalent IPv6 channel addresses resolve without textual spelling assumptions", () => { + const plan = build(value => { value.management.host = "2001:db8::1"; value.state.ipv4Addresses = []; value.state.ipv6Addresses = [{ ".id": "*22", address: "2001:0db8:0:0:0:0:0:1/64", interface: "ether-mgmt", disabled: "false" }] }) + assert.equal(plan.managementPath.addressId, "*22") +}) +function bridgeManagement() { + const value = fixture() + value.state.bridges[0]["vlan-filtering"] = "true" + value.state.bridges[0].pvid = "10" + value.state.ipv4Addresses[0].interface = "br-users" + value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", untagged: "br-users,ether2", disabled: "false" }] + value.desired.pvid = 10 + value.desired.vlans[0] = { id: "*11", vlanIds: "10", tagged: [trunk], untagged: [cpu, access], disabled: false } + return value +} +test("same-bridge CPU management allows multiple safe changes without a filtering outage", () => { + const value = bridgeManagement(), plan = buildBridgeVlanConfiguration(value) + assert.equal(plan.managementPath.bridgeName, "br-users") + assert.ok(plan.steps.length >= 2) + assert.ok(plan.steps.every(step => step.fields["vlan-filtering"] === undefined)) +}) +test("removing redundant explicit CPU untagged declaration preserves derived PVID membership", () => { + const value = bridgeManagement(); value.desired.vlans[0].untagged = [access] + assert.ok(buildBridgeVlanConfiguration(value).steps.length > 0) +}) +test("port removal and untagged migration cannot fabricate management safety", () => { + for (const change of [ + (value: ReturnType) => { value.desired.ports.shift(); value.desired.removePortIds = ["*10"]; value.desired.vlans[0].untagged = [cpu] }, + (value: ReturnType) => { value.desired.pvid = 20 }, + ]) { const value = bridgeManagement(); change(value); assert.throws(() => buildBridgeVlanConfiguration(value)) } +}) +test("a direct bridge CPU address also preserves existing tagged external management paths", () => { + const value = implicitPvidManagement() + value.state.ports.push({ ".id": "*12", bridge: "br-users", interface: "ether3", pvid: "1", "ingress-filtering": "true", "frame-types": "admit-only-vlan-tagged", disabled: "false" }) + value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "ether3", disabled: "false" }] + value.desired = bridgeVlanCurrentDesired(value.state, value.bridgeId) + value.desired.ports[0].ingressFiltering = false + assert.equal(buildBridgeVlanConfiguration(value).steps.length, 1) + value.desired.vlans[0].tagged = [] + assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management/) +}) +test("VLAN management derives VID and CPU membership from the configured parent", () => { + const value = fixture() + value.state.bridges[0]["vlan-filtering"] = "true" + value.state.ports[0]["frame-types"] = "admit-only-vlan-tagged" + value.state.ports[0].pvid = "1" + value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users,ether2", disabled: "false" }] + value.state.vlanInterfaces = [{ ".id": "*5", name: "mgmt-vlan", interface: "br-users", "vlan-id": "10", disabled: "false" }] + value.state.ipv4Addresses[0].interface = "mgmt-vlan" + value.desired.ports[0].pvid = 1; value.desired.ports[0].frameTypes = "admit-only-vlan-tagged" + value.desired.vlans[0] = { id: "*11", vlanIds: "10", tagged: [cpu, access, trunk], untagged: [], disabled: false } + const plan = buildBridgeVlanConfiguration(value) + assert.equal(plan.managementPath.vlanId, 10) + for (const [kind, id] of [["ipv4_address", "*20"], ["vlan", "*5"], ["bridge", "*1"], ["bridge_port", "*10"], ["ethernet", "*2"]]) assert.ok(plan.objects.some(object => object.kind === kind && object.id === id), `Missing protected management dependency ${kind}/${id}`) + assert.ok(plan.steps.every(step => step.fields["vlan-filtering"] === undefined)) + value.desired.vlans[0].tagged = [access, trunk] + assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management/) +}) +test("unknown PVID/frame types/flags and duplicate source identities refuse preparation", () => { + assert.throws(() => build(value => { delete value.state.ports[0].pvid })) + assert.throws(() => build(value => { value.state.ports[0]["frame-types"] = "unknown" })) + assert.throws(() => build(value => { value.state.bridges[0]["vlan-filtering"] = "unknown" })) + assert.throws(() => build(value => { value.state.interfaces.push({ ...value.state.interfaces[0] }) }), /Повтор/) +}) +test("unchanged full desired configuration has zero writes and omitted comments stay omitted", () => { + const value = bridgeManagement() + value.desired.ports.pop(); value.desired.vlans[0].tagged = [] + const plan = buildBridgeVlanConfiguration(value) + assert.deepEqual(plan.steps, []) + assert.equal(plan.inverse.desired.ports[0].comment, undefined) + assert.equal(plan.inverse.desired.vlans[0].comment, undefined) +}) +test("native projection to desired retains IDs and optional empty-field semantics", () => { + const value = bridgeManagement(), desired = bridgeVlanCurrentDesired(value.state, value.bridgeId) + assert.deepEqual(desired.vlans[0].tagged, []) + assert.equal(desired.ports[0].id, "*10") + assert.equal(desired.ports[0].comment, undefined) + assert.deepEqual(buildBridgeVlanConfiguration({ ...value, desired }).steps, []) +}) +test("unchanged native VID range syntax does not generate formatting-only mutations", () => { + const value = fixture() + value.state.ports[0]["frame-types"] = "admit-only-vlan-tagged" + value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "20-30,40", tagged: "br-users,ether2", disabled: "false" }] + const desired = bridgeVlanCurrentDesired(value.state, value.bridgeId) + assert.deepEqual(buildBridgeVlanConfiguration({ ...value, desired }).steps, []) +}) +test("dynamic PVID admission cannot conflict with an explicit tagged membership", () => { + assert.throws(() => build(value => { + value.desired.ports[1].frameTypes = "admit-all"; value.desired.ports[1].pvid = 10 + }), /PVID создаёт конфликт/) + assert.throws(() => build(value => { value.desired.pvid = 10 }), /CPU PVID/) +}) +test("unfiltered management ignores ingress flags: every existing L2 port is preserved", () => { + const value = fixture() + value.state.ipv4Addresses[0].interface = "br-users" + value.state.bridges[0].pvid = "10" + value.state.ports[0].pvid = "99" + value.desired.pvid = 10 + value.desired.vlans[0].untagged = [cpu, access]; value.desired.vlans[0].tagged = [trunk] + assert.ok(buildBridgeVlanConfiguration(value).steps.length > 0) + value.desired.ports[0].frameTypes = "admit-only-vlan-tagged" + value.desired.vlans[0].untagged = [cpu]; value.desired.vlans[0].tagged = [access, trunk] + assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management/) +}) +test("adding a management parent as a bridge port cannot be presented as independence", () => { + assert.throws(() => build(value => { + value.desired.ports.push({ interface: { resource: "ethernet", id: "*4" }, pvid: 10, ingressFiltering: true, frameTypes: "admit-all", disabled: false }) + value.desired.vlans[0].untagged.push({ resource: "ethernet", id: "*4" }) + }), /management интерфейс в slave/) +}) +test("inverse explicitly clears a newly assigned previously absent comment", () => { + const plan = build(value => { value.desired.ports[0].comment = "new comment" }) + assert.equal(plan.inverse.desired.ports[0].comment, "") +}) +test("independent management address, bridge and actual port dependencies enter protection policy", () => { + const value = fixture() + value.state.bridges.push({ ".id": "*40", name: "br-mgmt", disabled: "false", pvid: "1", "vlan-filtering": "false" }) + value.state.interfaces.push({ resource: "bridge", id: "*40", name: "br-mgmt", disabled: "false" }) + value.state.ports.push({ ".id": "*41", bridge: "br-mgmt", interface: "ether-mgmt", pvid: "1", "frame-types": "admit-all", disabled: "false" }) + value.state.ipv4Addresses[0].interface = "br-mgmt" + const plan = buildBridgeVlanConfiguration(value) + for (const [kind, id] of [["ipv4_address", "*20"], ["bridge", "*40"], ["bridge_port", "*41"], ["ethernet", "*4"]]) assert.ok(plan.objects.some(object => object.kind === kind && object.id === id), `Missing protected management dependency ${kind}/${id}`) +}) +test("reassigned old port and removed old VLAN members remain in protected references", () => { + const value = fixture() + value.desired.filtering = false + value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "ether2", disabled: "false" }] + value.desired.ports = [{ ...value.desired.ports[0], interface: trunk }] + value.desired.vlans = [{ id: "*11", vlanIds: "10", tagged: [trunk], untagged: [], disabled: false }] + const plan = buildBridgeVlanConfiguration(value) + assert.ok(plan.objects.some(object => object.kind === "ethernet" && object.id === access.id)) + assert.ok(plan.objects.some(object => object.kind === "ethernet" && object.id === trunk.id)) +}) +test("L3 GRE/WireGuard native bridge ports are rejected before any plan is emitted", () => { + for (const resource of ["gre", "wireguard"]) { + const value = fixture() + value.state.interfaces.find(iface => iface.id === trunk.id)!.resource = resource + value.desired.ports[1].interface.resource = resource + value.desired.vlans[0].tagged[1].resource = resource + assert.throws(() => buildBridgeVlanConfiguration(value), error => error instanceof Error && "code" in error && error.code === "UNSUPPORTED_CAPABILITY") + } +}) +test("catalogued L2 VLAN and VXLAN ports remain available", () => { + for (const resource of ["vlan", "vxlan"]) { + const value = fixture() + value.state.interfaces.find(iface => iface.id === trunk.id)!.resource = resource + value.desired.ports[1].interface.resource = resource + value.desired.vlans[0].tagged[1].resource = resource + if (resource === "vlan") value.state.vlanInterfaces.push({ ".id": trunk.id, name: "ether3", interface: "ether-mgmt", "vlan-id": "101", disabled: "false" }) + assert.ok(buildBridgeVlanConfiguration(value).steps.some(step => step.resource === "bridge_port" && step.fields.interface === "ether3")) + } +}) +test("a VLAN over the selected bridge cannot be fed back into that bridge", () => { + const value = fixture() + value.state.interfaces.find(iface => iface.id === trunk.id)!.resource = "vlan" + value.desired.ports[1].interface.resource = "vlan"; value.desired.vlans[0].tagged[1].resource = "vlan" + value.state.vlanInterfaces.push({ ".id": trunk.id, name: "ether3", interface: "br-users", "vlan-id": "101", disabled: "false" }) + assert.throws(() => buildBridgeVlanConfiguration(value), /собственные порты/) +}) +test("explicit port replacement deletes old interface claim before native CREATE", () => { + const value = fixture() + delete value.desired.ports[0].id; value.desired.removePortIds = ["*10"] + const plan = buildBridgeVlanConfiguration(value) + const removal = plan.steps.findIndex(step => step.resource === "bridge_port" && step.action === "delete" && step.id === "*10") + const addition = plan.steps.findIndex(step => step.resource === "bridge_port" && step.action === "create" && step.fields.interface === "ether2") + assert.ok(removal >= 0 && addition > removal) + const acks = plan.steps.flatMap((step, stepIndex) => step.action === "create" ? [{ stepIndex, id: `*${(stepIndex + 100).toString(16)}` }] : []) + const inverse = bridgeVlanInverseDesired(plan, acks) + assert.equal(inverse.ports[0].id, undefined) + assert.ok(inverse.removePortIds.includes(acks.find(ack => ack.stepIndex === addition)!.id)) +}) +test("swapping existing claimed interfaces has no executable order and refuses before publication", () => { + const value = fixture() + value.state.ports.push({ ".id": "*12", bridge: "br-users", interface: "ether3", pvid: "1", "ingress-filtering": "true", "frame-types": "admit-only-vlan-tagged", disabled: "true" }) + value.desired.ports[1].id = "*12" + value.desired.ports[0].interface = trunk; value.desired.ports[1].interface = access + value.desired.filtering = false; value.desired.vlans = [] + const before = structuredClone(value) + assert.throws(() => buildBridgeVlanConfiguration(value), /конфликта интерфейсов/) + assert.deepEqual(value, before) +}) diff --git a/backend/src/services/bridge-vlan-configuration.ts b/backend/src/services/bridge-vlan-configuration.ts new file mode 100644 index 00000000..451dd7a3 --- /dev/null +++ b/backend/src/services/bridge-vlan-configuration.ts @@ -0,0 +1,338 @@ +import { isIP } from "node:net" +import type { z } from "zod" +import { bridgeVlanDesiredSchema, bridgeVlanRowIdSchema as id, bridgeVlanInterfaceReferenceSchema as reference, bridgeVlanIdSchema as vid, bridgeVlanFrameTypesSchema as frames, type BridgeVlanDesired, type BridgeVlanState } from "@mmapp/contracts/bridge-vlan" +import type { ConfigurationRow, RosStep } from "../modules/mcp/resources.js" +export { bridgeVlanDesiredSchema, type BridgeVlanDesired, type BridgeVlanState } +export type BridgeVlanInterface = { resource: string; id: string; name: string; disabled?: unknown } +/** This argument belongs to the trusted server adapter, never the public request. + * The adapter verifies channel identity/revocation and decrypts its literal host. + * These values and all source snapshots must be sealed and rechecked on apply. */ +export type BridgeVlanManagement = { channelId: string; serverId: number; expiresAt: string; host: string } +export class BridgeVlanConfigurationError extends Error { + constructor(readonly code: "INVALID_INPUT" | "STATE_CONFLICT" | "MANAGEMENT_UNSAFE" | "UNSUPPORTED_CAPABILITY", message: string) { super(message) } +} +/** L2 port kinds available in the actual typed configuration catalogue. + * RouterOS also supports EoIP/bond/Wi-Fi, but they need their own indexed + * configuration resource before this planner can prove their stable identity. + * GRE and WireGuard carry L3 traffic and cannot be native bridge ports. + * https://manual.mikrotik.com/docs/bridging-and-switching/ + * https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE + * https://help.mikrotik.com/docs/spaces/ROS/pages/69664792/WireGuard */ +export const BRIDGE_VLAN_L2_PORT_RESOURCES = ["ethernet", "vlan", "vxlan"] as const +function reject(message: string, code: BridgeVlanConfigurationError["code"] = "STATE_CONFLICT"): never { throw new BridgeVlanConfigurationError(code, message) } +const text = (row: ConfigurationRow, key: string): string => typeof row[key] === "string" ? row[key] as string : reject(`Не подтверждено поле ${key}`) +function flag(value: unknown): boolean { + if ([true, "true", "yes"].includes(value as boolean | string)) return true + if ([false, "false", "no"].includes(value as boolean | string)) return false + return reject("Не подтверждено логическое свойство") +} +const mutable = (row: ConfigurationRow) => !["dynamic", "builtin", "default"].some(key => row[key] !== undefined && flag(row[key])) +const enabled = (row: ConfigurationRow) => !flag(row.disabled) +const rowId = (row: ConfigurationRow) => id.parse(row[".id"]) +const keyOf = (value: { resource: string; id: string }) => `${value.resource}:${value.id}` +const unique = (values: string[], label: string) => { if (new Set(values).size !== values.length) reject(`Повтор ${label}`, "INVALID_INPUT") } + +/** Ranges are inclusive. Reject overlaps rather than silently deduplicating. */ +export function parseBridgeVlanIds(value: string): number[] { + const found = new Set() + for (const part of value.split(",")) { + if (!/^[1-9]\d{0,3}(?:-[1-9]\d{0,3})?$/.test(part)) reject("Некорректный VLAN ID/диапазон", "INVALID_INPUT") + const [start, rawEnd] = part.split("-").map(Number), end = rawEnd ?? start + if (start < 1 || end > 4094 || end < start) reject("VLAN ID вне 1..4094", "INVALID_INPUT") + for (let n = start; n <= end; n++) { if (found.has(n)) reject("Повтор VLAN ID", "INVALID_INPUT"); found.add(n) } + } + return [...found].sort((a, b) => a - b) +} +const names = (row: ConfigurationRow, field: string): string[] => { + if (row[field] === undefined && ["tagged", "untagged"].includes(field)) return [] + const value = text(row, field) + if (!value) return [] + const result = value.split(",") + if (result.some(name => !name || name.trim() !== name)) reject("Неоднозначный список интерфейсов") + unique(result, "интерфейса") + return result +} +const numberVid = (value: unknown) => vid.parse(typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value) +type Topology = { bridge: ConfigurationRow; ports: ConfigurationRow[]; vlans: ConfigurationRow[] } +type Admission = { interface: string; mode: "tagged" | "untagged" } +export type BridgeVlanConfigurationPlan = { + serverId: number; bridgeId: string; steps: RosStep[]; + objects: Array<{ kind: string; id: string; serverId: number }>; + managementPath: { channelId: string; addressId: string; interfaceName: string; bridgeName?: string; vlanId?: number }; + inverse: { desired: BridgeVlanDesired; created: Array<{ stepIndex: number; resource: "bridge_port" | "bridge_vlan" }> }; +} + +/** Convert observed static rows without replacing unknown native properties + * with assumed defaults. Used by the editor and generic effective PATCH guard. */ +export function bridgeVlanCurrentDesired(state: BridgeVlanState, bridgeId: string): BridgeVlanDesired { + const bridges = state.bridges.filter(row => row[".id"] === bridgeId) + if (bridges.length !== 1) reject("Bridge не найден однозначно") + const bridge = bridges[0], bridgeName = text(bridge, "name") + const refFor = (name: string) => { + const found = state.interfaces.filter(value => value.name === name) + if (found.length !== 1) reject("Не подтверждена идентичность интерфейса") + return { resource: found[0].resource, id: id.parse(found[0].id) } + } + return { + filtering: flag(bridge["vlan-filtering"]), pvid: numberVid(bridge.pvid), frameTypes: frames.parse(bridge["frame-types"]), ingressFiltering: flag(bridge["ingress-filtering"]), + ports: state.ports.filter(row => row.bridge === bridgeName && mutable(row)).map(row => ({ id: rowId(row), interface: refFor(text(row, "interface")), pvid: numberVid(row.pvid), ingressFiltering: flag(row["ingress-filtering"]), frameTypes: frames.parse(row["frame-types"]), disabled: flag(row.disabled), ...(row.comment === undefined ? {} : { comment: text(row, "comment") }) })), + vlans: state.vlans.filter(row => row.bridge === bridgeName && mutable(row)).map(row => ({ id: rowId(row), vlanIds: text(row, "vlan-ids"), tagged: names(row, "tagged").map(refFor), untagged: names(row, "untagged").map(refFor), disabled: flag(row.disabled), ...(row.comment === undefined ? {} : { comment: text(row, "comment") }) })), + removePortIds: [], removeVlanIds: [], + } +} + +export function buildBridgeVlanConfiguration(input: { + serverId: number; bridgeId: string; desired: BridgeVlanDesired; state: BridgeVlanState; management: BridgeVlanManagement; now: number; +}): BridgeVlanConfigurationPlan { + const { serverId, bridgeId, state, management } = input, desired = bridgeVlanDesiredSchema.parse(input.desired) + if (!Number.isSafeInteger(serverId) || serverId <= 0 || management.serverId !== serverId || !management.channelId || !Number.isFinite(input.now) || !(Date.parse(management.expiresAt) > input.now) || !isIP(management.host)) reject("Нет актуального канала управления", "MANAGEMENT_UNSAFE") + const bridges = state.bridges.filter(row => row[".id"] === bridgeId) + if (bridges.length !== 1 || !mutable(bridges[0]) || !enabled(bridges[0])) reject("Bridge не подтверждён или отключён") + const bridge = bridges[0], bridgeName = text(bridge, "name") + // A dynamic VLAN table is not evidence of caller-owned rows. PVID-generated + // untagged membership is derived below; protocol registrations are unsupported. + if (bridge.mvrp !== undefined && flag(bridge.mvrp)) reject("MVRP создаёт программируемые VLAN memberships; этот режим не поддерживается", "UNSUPPORTED_CAPABILITY") + const cpuFrames = frames.parse(bridge["frame-types"]), cpuIngress = flag(bridge["ingress-filtering"]) + const byRef = new Map(state.interfaces.map(iface => [keyOf(iface), iface])) + unique(state.interfaces.map(keyOf), "идентичности интерфейса") + unique(state.interfaces.map(iface => iface.name), "имени интерфейса") + const bridgeRef = state.interfaces.find(iface => iface.resource === "bridge" && iface.id === bridgeId && iface.name === bridgeName) + if (!bridgeRef) reject("Не подтверждена идентичность CPU bridge") + const resolve = (ref: z.infer) => byRef.get(keyOf(ref)) ?? reject("Неизвестный стабильный ID интерфейса", "INVALID_INPUT") + const oldPorts = state.ports.filter(row => row.bridge === bridgeName), oldVlans = state.vlans.filter(row => row.bridge === bridgeName) + unique(state.ports.map(rowId), "ID порта"); unique(state.vlans.map(rowId), "ID membership") + // Dynamic rows remain owned by RouterOS; they are never converted to commands. + const staticPorts = oldPorts.filter(mutable), staticVlans = oldVlans.filter(mutable) + const account = (rows: ConfigurationRow[], keep: Array<{ id?: string }>, remove: string[], label: string) => { + const requested = [...keep.flatMap(row => row.id ? [row.id] : []), ...remove] + unique(requested, label) + const existing = rows.map(rowId) + if (requested.length !== existing.length || requested.some(value => !existing.includes(value))) reject(`Нужен явный полный список ${label}`, "INVALID_INPUT") + } + account(staticPorts, desired.ports, desired.removePortIds, "портов") + account(staticVlans, desired.vlans, desired.removeVlanIds, "VLAN memberships") + unique(desired.ports.map(port => keyOf(port.interface)), "порта bridge") + const portRows = desired.ports.map((port, index) => { + const iface = resolve(port.interface) + if (iface.name === bridgeName || iface.resource === "bridge" || state.ports.some(row => row.interface === iface.name && row.bridge !== bridgeName)) reject("Интерфейс уже принадлежит другому bridge или является bridge", "INVALID_INPUT") + if (!(BRIDGE_VLAN_L2_PORT_RESOURCES as readonly string[]).includes(iface.resource)) reject("Тип интерфейса не поддерживает подтверждённый L2 bridge port", "UNSUPPORTED_CAPABILITY") + let parent = iface.name + const ancestors = new Set() + while (true) { + if (ancestors.has(parent)) reject("Цикл VLAN родителей bridge-порта", "INVALID_INPUT") + ancestors.add(parent) + const vlans = state.vlanInterfaces.filter(row => row.name === parent) + if (!vlans.length) { + if (parent === iface.name && iface.resource === "vlan") reject("Не подтверждён VLAN parent bridge-порта", "UNSUPPORTED_CAPABILITY") + break + } + if (vlans.length !== 1) reject("Неоднозначный VLAN parent bridge-порта", "STATE_CONFLICT") + parent = text(vlans[0], "interface") + if (parent === bridgeName) reject("VLAN над выбранным bridge нельзя вернуть в его собственные порты", "INVALID_INPUT") + } + if (!port.disabled && iface.disabled !== undefined && flag(iface.disabled)) reject("Порт ссылается на отключённый интерфейс", "INVALID_INPUT") + const previous = staticPorts.find(row => row[".id"] === port.id) + return { ".id": port.id ?? `new-port-${index}`, bridge: bridgeName, interface: iface.name, pvid: String(port.pvid), "ingress-filtering": String(port.ingressFiltering), "frame-types": port.frameTypes, disabled: String(port.disabled), ...(port.comment !== undefined ? { comment: port.comment } : previous?.comment !== undefined ? { comment: text(previous, "comment") } : {}) } + }) + const vlanRows = desired.vlans.map((vlan, index) => { + const tagged = vlan.tagged.map(ref => resolve(ref).name), untagged = vlan.untagged.map(ref => resolve(ref).name) + unique([...tagged, ...untagged], "tagged/untagged участника") + parseBridgeVlanIds(vlan.vlanIds) + const previous = staticVlans.find(row => row[".id"] === vlan.id) + return { ".id": vlan.id ?? `new-vlan-${index}`, bridge: bridgeName, "vlan-ids": vlan.vlanIds, ...(tagged.length || previous?.tagged !== undefined ? { tagged: tagged.join(",") } : {}), ...(untagged.length || previous?.untagged !== undefined ? { untagged: untagged.join(",") } : {}), disabled: String(vlan.disabled), ...(vlan.comment !== undefined ? { comment: vlan.comment } : previous?.comment !== undefined ? { comment: text(previous, "comment") } : {}) } + }) + const target: Topology = { bridge: { ...bridge, pvid: String(desired.pvid), "vlan-filtering": String(desired.filtering), "frame-types": desired.frameTypes ?? cpuFrames, "ingress-filtering": String(desired.ingressFiltering ?? cpuIngress) }, ports: [...oldPorts.filter(row => !mutable(row)), ...portRows], vlans: [...oldVlans.filter(row => !mutable(row)), ...vlanRows] } + const activePorts = (topology: Topology) => topology.ports.filter(enabled) + const membership = (topology: Topology, vlanId: number, mode: "tagged" | "untagged") => { + const members = new Set(topology.vlans.filter(row => mutable(row) && enabled(row) && parseBridgeVlanIds(text(row, "vlan-ids")).includes(vlanId)).flatMap(row => names(row, mode))) + // RouterOS automatically includes CPU/ports that accept untagged ingress + // as untagged PVID members. Never synthesize a VLAN table command for it. + if (mode === "untagged") for (const port of [topology.bridge, ...activePorts(topology)]) { + if (enabled(port) && frames.parse(port["frame-types"]) !== "admit-only-vlan-tagged" && numberVid(port.pvid) === vlanId) members.add(port === topology.bridge ? bridgeName : text(port, "interface")) + } + return members + } + // Validate effective static state; dynamic PVID membership is derived from + // the actual port PVID, never accepted as caller-owned VLAN configuration. + const validate = (topology: Topology) => { + const ports = activePorts(topology), allowed = new Set([bridgeName, ...topology.ports.map(row => text(row, "interface"))]) + const used = new Set(), configuredVids = new Set() + for (const vlan of topology.vlans.filter(row => mutable(row) && enabled(row))) { + const vids = parseBridgeVlanIds(text(vlan, "vlan-ids")), tagged = names(vlan, "tagged"), untagged = names(vlan, "untagged") + unique([...tagged, ...untagged], "tagged/untagged участника") + if ([...tagged, ...untagged].some(name => !allowed.has(name))) reject("Участник VLAN не принадлежит выбранному bridge", "INVALID_INPUT") + for (const n of vids) { if (configuredVids.has(n)) reject("Неоднозначная эффективная VLAN membership", "INVALID_INPUT"); configuredVids.add(n) } + for (const n of vids) for (const name of [...tagged, ...untagged]) { + const key = `${n}:${name}` + if (used.has(key)) reject("Неоднозначная эффективная VLAN membership", "INVALID_INPUT") + used.add(key) + const pvid = name === bridgeName ? numberVid(topology.bridge.pvid) : numberVid(topology.ports.find(row => row.interface === name)!.pvid) + if (untagged.includes(name) && (vids.length !== 1 || pvid !== n)) reject("Untagged VLAN не соответствует PVID", "INVALID_INPUT") + } + } + if (flag(topology.bridge["vlan-filtering"])) for (const port of ports) { + const frameTypes = frames.parse(port["frame-types"]) + if (frameTypes !== "admit-only-vlan-tagged" && membership(topology, numberVid(port.pvid), "tagged").has(text(port, "interface"))) reject("PVID создаёт конфликт tagged/untagged membership", "INVALID_INPUT") + } + if (flag(topology.bridge["vlan-filtering"]) && frames.parse(topology.bridge["frame-types"]) !== "admit-only-vlan-tagged" && membership(topology, numberVid(topology.bridge.pvid), "tagged").has(bridgeName)) reject("CPU PVID создаёт конфликт tagged/untagged membership", "INVALID_INPUT") + } + validate(target) + const addresses = [...state.ipv4Addresses, ...state.ipv6Addresses].filter(row => typeof row.address === "string" && isIP(row.address.split("/")[0]) === isIP(management.host) && sameAddress(row.address.split("/")[0], management.host) && enabled(row)) + if (addresses.length !== 1) reject("Адрес канала не найден однозначно в конфигурации", "MANAGEMENT_UNSAFE") + const address = addresses[0], addressInterface = text(address, "interface") + let managementInterface = addressInterface, managementVid: number | undefined + const visited = new Set() + while (true) { + if (visited.has(managementInterface)) reject("Цикл родителей management", "MANAGEMENT_UNSAFE") + visited.add(managementInterface) + const candidates = state.vlanInterfaces.filter(row => row.name === managementInterface) + if (!candidates.length) break + if (candidates.length !== 1 || !enabled(candidates[0]) || managementVid !== undefined) reject("Не подтверждён management VLAN parent", "MANAGEMENT_UNSAFE") + managementVid = numberVid(candidates[0]["vlan-id"]); managementInterface = text(candidates[0], "interface") + } + const managementPort = state.ports.find(row => row.interface === managementInterface) + const affected = managementInterface === bridgeName || managementPort?.bridge === bridgeName + // An IP directly assigned to a bridge slave does not prove CPU delivery. + if (managementPort?.bridge === bridgeName) reject("Management IP на slave-порту не доказывает CPU-доступ", "MANAGEMENT_UNSAFE") + if (target.ports.some(row => row.interface === managementInterface)) reject("План превращает management интерфейс в slave-порт", "MANAGEMENT_UNSAFE") + const managementBase = state.interfaces.find(iface => iface.name === managementInterface) + if (!managementBase || !["ethernet", "bridge"].includes(managementBase.resource)) reject("Не подтверждён физический management parent", "MANAGEMENT_UNSAFE") + if (managementPort) reject("Management IP на slave-порту не доказывает CPU-доступ", "MANAGEMENT_UNSAFE") + // A different bridge is independent only if none of its actual member + // interfaces depends on this bridge (for example a VLAN over this bridge). + const dependentOnTarget = (name: string, visiting = new Set()): boolean => { + if (name === bridgeName || target.ports.some(row => row.interface === name)) return true + if (visiting.has(name)) reject("Цикл management зависимостей", "MANAGEMENT_UNSAFE") + const next = new Set(visiting).add(name) + const vlan = state.vlanInterfaces.find(row => row.name === name) + if (vlan) return dependentOnTarget(text(vlan, "interface"), next) + const iface = state.interfaces.find(item => item.name === name) + if (!iface || !["ethernet", "bridge"].includes(iface.resource)) reject("Не подтверждены management зависимости", "MANAGEMENT_UNSAFE") + return iface.resource === "bridge" && state.ports.filter(row => row.bridge === name && enabled(row)).some(row => dependentOnTarget(text(row, "interface"), next)) + } + if (!affected && dependentOnTarget(managementInterface)) reject("Management зависит от изменяемого bridge", "MANAGEMENT_UNSAFE") + if (!state.interfaces.some(iface => iface.name === managementInterface && (iface.disabled === undefined || !flag(iface.disabled)))) reject("Не подтверждён management интерфейс", "MANAGEMENT_UNSAFE") + const admissions = (topology: Topology): Admission[] => { + if (!affected) return [] + const filtering = flag(topology.bridge["vlan-filtering"]), vlanId = managementVid ?? numberVid(topology.bridge.pvid) + if (!filtering) return activePorts(topology).map(port => ({ interface: text(port, "interface"), mode: managementVid === undefined ? "untagged" as const : "tagged" as const })) + const cpuMode = managementVid === undefined ? "untagged" : "tagged" + const cpuFrameTypes = frames.parse(topology.bridge["frame-types"]) + if (cpuMode === "untagged" && cpuFrameTypes === "admit-only-vlan-tagged" || cpuMode === "tagged" && cpuFrameTypes === "admit-only-untagged-and-priority-tagged") return [] + if (filtering && !membership(topology, vlanId, cpuMode).has(bridgeName)) return [] + if (membership(topology, vlanId, "tagged").has(bridgeName) && membership(topology, vlanId, "untagged").has(bridgeName)) return [] + return activePorts(topology).flatMap(port => { + const name = text(port, "interface"), frameTypes = frames.parse(port["frame-types"]), result: Admission[] = [] + if (membership(topology, vlanId, "tagged").has(name) && membership(topology, vlanId, "untagged").has(name)) return [] + if (frameTypes !== "admit-only-untagged-and-priority-tagged" && membership(topology, vlanId, "tagged").has(name)) result.push({ interface: name, mode: "tagged" }) + if (frameTypes !== "admit-only-vlan-tagged" && numberVid(port.pvid) === vlanId && (!filtering || membership(topology, vlanId, "untagged").has(name))) result.push({ interface: name, mode: "untagged" }) + return result + }) + } + let current: Topology = structuredClone({ bridge, ports: oldPorts, vlans: oldVlans }) + const baseline = admissions(current) + if (affected && !baseline.length) reject("Не подтверждён исходный CPU management-доступ", "MANAGEMENT_UNSAFE") + const preservesManagement = (topology: Topology) => !affected || baseline.every(old => admissions(topology).some(next => old.interface === next.interface && old.mode === next.mode)) + if (!preservesManagement(target)) reject("План изменяет подтверждённые пути CPU management", "MANAGEMENT_UNSAFE") + const inverseDesired = bridgeVlanCurrentDesired(state, bridgeId) + for (const port of inverseDesired.ports) { + if (desired.removePortIds.includes(port.id!)) delete port.id + else if (port.comment === undefined && desired.ports.some(next => next.id === port.id && next.comment !== undefined)) port.comment = "" + } + for (const vlan of inverseDesired.vlans) { + if (desired.removeVlanIds.includes(vlan.id!)) delete vlan.id + else if (vlan.comment === undefined && desired.vlans.some(next => next.id === vlan.id && next.comment !== undefined)) vlan.comment = "" + } + const steps: RosStep[] = [], created: BridgeVlanConfigurationPlan["inverse"]["created"] = [] + const append = (step: RosStep) => { if (step.action === "create") created.push({ stepIndex: steps.length, resource: step.resource as "bridge_port" | "bridge_vlan" }); steps.push(step) } + const mutation = (resource: "bridge_port" | "bridge_vlan", old: ConfigurationRow[], next: ConfigurationRow[], deleted: string[]) => { + const result: Array<{ step: RosStep; modelId: string }> = [] + for (const row of next) { + const nativeId = String(row[".id"]), previous = old.find(value => value[".id"] === nativeId) + const fields = Object.fromEntries(Object.entries(row).filter(([key, value]) => key !== ".id" && (!previous || String(previous[key]) !== value)).map(([key, value]) => [key, String(value)])) + if (Object.keys(fields).length) result.push({ step: { serverId, resource, action: previous ? "update" : "create", ...(previous ? { id: nativeId } : {}), fields }, modelId: nativeId }) + } + for (const nativeId of deleted) result.push({ step: { serverId, resource, action: "delete", id: nativeId, fields: {} }, modelId: nativeId }) + return result + } + const pending = [...mutation("bridge_vlan", staticVlans, vlanRows, desired.removeVlanIds), ...mutation("bridge_port", staticPorts, portRows, desired.removePortIds)] + // Independent management permits a disabled intermediate topology. A channel + // on this bridge instead requires every acknowledged prefix to preserve it. + if (flag(bridge["vlan-filtering"]) && (!desired.filtering || pending.length > 0) && !affected) { + append({ serverId, resource: "bridge", action: "update", id: bridgeId, fields: { "vlan-filtering": "false" } }); current.bridge["vlan-filtering"] = "false" + } + while (pending.length) { + let selected = -1, next: Topology | undefined + for (let index = 0; index < pending.length; index++) { + const item = pending[index], candidate = structuredClone(current), field = item.step.resource === "bridge_port" ? "ports" : "vlans" + if (item.step.action === "delete") candidate[field] = candidate[field].filter(row => row[".id"] !== item.modelId) + else if (item.step.action === "create") candidate[field].push({ ".id": item.modelId, ...item.step.fields }) + else candidate[field] = candidate[field].map(row => row[".id"] === item.modelId ? { ...row, ...item.step.fields } : row) + // RouterOS permits only one bridge-port row per interface, including + // disabled rows. Explicit replacement must DELETE before CREATE, and + // swaps requiring an invented temporary interface are not admitted. + const claimed = candidate.ports.map(row => text(row, "interface")) + if (new Set(claimed).size !== claimed.length) continue + if (preservesManagement(candidate)) { selected = index; next = candidate; break } + } + if (selected < 0 || !next) reject("Не существует порядка без конфликта интерфейсов или потери management", "STATE_CONFLICT") + append(pending.splice(selected, 1)[0].step); current = next + } + const finalFields: Record = {} + if (numberVid(current.bridge.pvid) !== desired.pvid) finalFields.pvid = String(desired.pvid) + if (desired.frameTypes !== undefined && frames.parse(current.bridge["frame-types"]) !== desired.frameTypes) finalFields["frame-types"] = desired.frameTypes + if (desired.ingressFiltering !== undefined && flag(current.bridge["ingress-filtering"]) !== desired.ingressFiltering) finalFields["ingress-filtering"] = String(desired.ingressFiltering) + if (flag(current.bridge["vlan-filtering"]) !== desired.filtering) finalFields["vlan-filtering"] = String(desired.filtering) + if (Object.keys(finalFields).length) { + if (!preservesManagement({ ...current, bridge: { ...current.bridge, ...finalFields } })) reject("Финальное переключение теряет management", "MANAGEMENT_UNSAFE") + append({ serverId, resource: "bridge", action: "update", id: bridgeId, fields: finalFields }) + } + const objectMap = new Map() + for (const [kind, rows] of [["bridge", [bridge]], ["bridge_port", oldPorts], ["bridge_vlan", oldVlans]] as const) for (const row of rows) objectMap.set(`${kind}:${rowId(row)}`, { kind, id: rowId(row), serverId }) + for (const ref of [...desired.ports.map(port => port.interface), ...desired.vlans.flatMap(vlan => [...vlan.tagged, ...vlan.untagged])]) objectMap.set(keyOf(ref), { kind: ref.resource, id: ref.id, serverId }) + const oldMemberNames = new Set([...oldPorts.map(row => text(row, "interface")), ...oldVlans.flatMap(row => [...names(row, "tagged"), ...names(row, "untagged")])]) + for (const name of oldMemberNames) { + const iface = state.interfaces.find(value => value.name === name) ?? reject("Не подтверждена прежняя зависимость bridge") + objectMap.set(keyOf(iface), { kind: iface.resource, id: iface.id, serverId }) + } + const addressResource = state.ipv4Addresses.includes(address) ? "ipv4_address" : "ipv6_address" + objectMap.set(`${addressResource}:${rowId(address)}`, { kind: addressResource, id: rowId(address), serverId }) + const managementVisited = new Set() + const includeManagement = (name: string) => { + if (managementVisited.has(name)) return + managementVisited.add(name) + const iface = state.interfaces.find(value => value.name === name) ?? reject("Не подтверждена идентичность management dependency") + objectMap.set(keyOf(iface), { kind: iface.resource, id: iface.id, serverId }) + const vlan = state.vlanInterfaces.find(row => row.name === name) + if (vlan) includeManagement(text(vlan, "interface")) + if (iface.resource === "bridge") for (const port of state.ports.filter(row => row.bridge === name && enabled(row))) { + objectMap.set(`bridge_port:${rowId(port)}`, { kind: "bridge_port", id: rowId(port), serverId }) + includeManagement(text(port, "interface")) + } + } + includeManagement(addressInterface) + return { serverId, bridgeId, steps, objects: [...objectMap.values()], managementPath: { channelId: management.channelId, addressId: rowId(address), interfaceName: addressInterface, ...(affected ? { bridgeName, ...(managementVid === undefined ? {} : { vlanId: managementVid }) } : {}) }, inverse: { desired: inverseDesired, created } } +} + +function sameAddress(left: string, right: string): boolean { + if (isIP(left) === 4) return left === right + // WHATWG URL canonicalizes equivalent compressed IPv6 spellings. + try { return new URL(`http://[${left}]/`).hostname === new URL(`http://[${right}]/`).hostname } catch { return false } +} + +/** ACKs must come from successful executor read-back, never from user JSON. + * Re-prepare this desired state against fresh snapshots and obtain approval; + * this function grants no automatic compensation or replay authority. */ +export function bridgeVlanInverseDesired(plan: BridgeVlanConfigurationPlan, createAcks: Array<{ stepIndex: number; id: string }>): BridgeVlanDesired { + unique(createAcks.map(ack => String(ack.stepIndex)), "create ACK") + if (createAcks.length !== plan.inverse.created.length) reject("Нет точных ACK всех созданных объектов") + const desired = structuredClone(plan.inverse.desired) + for (const created of plan.inverse.created) { + const ack = createAcks.find(value => value.stepIndex === created.stepIndex) + if (!ack) reject("Нет create ACK") + const nativeId = id.parse(ack.id) + if (created.resource === "bridge_port") desired.removePortIds.push(nativeId) + else desired.removeVlanIds.push(nativeId) + } + return desired +} diff --git a/backend/src/services/bridge-vlan-values.ts b/backend/src/services/bridge-vlan-values.ts new file mode 100644 index 00000000..73efa10f --- /dev/null +++ b/backend/src/services/bridge-vlan-values.ts @@ -0,0 +1,31 @@ +/** RouterOS VLAN IDs and membership are sets, not row-order-sensitive strings. + * Never apply this normalization to interface names or arbitrary properties. */ +export function canonicalBridgeVlanValue(field: string, value: unknown): unknown { + if (["tagged", "untagged"].includes(field)) { + if (value === undefined || value === "") return "" + if (typeof value !== "string") throw Error("Invalid VLAN members") + const names = value.split(",") + if (names.some(name => !name || name.trim() !== name) || new Set(names).size !== names.length) throw Error("Ambiguous VLAN members") + return names.sort().join(",") + } + if (field !== "vlan-ids") return value + if (typeof value !== "string" || !value.length) throw Error("Invalid VLAN IDs") + const ids = new Set() + for (const part of value.split(",")) { + if (!/^[1-9]\d{0,3}(?:-[1-9]\d{0,3})?$/.test(part)) throw Error("Invalid VLAN IDs") + const [start, rawEnd] = part.split("-").map(Number), end = rawEnd ?? start + if (start < 1 || end > 4094 || end < start) throw Error("Invalid VLAN IDs") + for (let id = start; id <= end; id++) { + if (ids.has(id)) throw Error("Overlapping VLAN IDs") + ids.add(id) + } + } + const sorted = [...ids].sort((a, b) => a - b), ranges: string[] = [] + for (let index = 0; index < sorted.length;) { + const start = sorted[index] + let end = start + while (++index < sorted.length && sorted[index] === end + 1) end = sorted[index] + ranges.push(start === end ? String(start) : `${start}-${end}`) + } + return ranges.join(",") +} diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index 8fa251ab..d3d9183c 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -236,7 +236,7 @@ export async function fetchIpsecState(server: ServerRow, reader?: MikrotikClient * 2) PSK-identity. * Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех. */ -export function mapClients(state: IpsecServerState): IpsecClientDto[] { +export function mapClients(state: Pick): IpsecClientDto[] { const server = state.server const ike2Peers = selectIke2Peers(state.peers) const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) @@ -414,6 +414,13 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { return out.sort((a, b) => a.name.localeCompare(b.name)) } +/** Binding selectors reuse the existing client eligibility/name/CN rules, + * including PSK and shared-listener exclusion. No online/static-IP observation + * is claimed: this projection returns keys only from the three native menus. */ +export function ipsecClientBindingKeys(state: Pick): string[] { + return mapClients({ ...state, modeConfigs: [], active: [] }).map(client => client.commonName ?? client.name) +} + /** Explicit namespaces prevent cross-menu RouterOS ID collisions. Legacy ambiguous IDs fail closed. */ export function resolveIpsecClientTarget(state: IpsecServerState, token: string) { const kind = token.startsWith("cert:") ? "cert" : token.startsWith("identity:") ? "identity" : undefined diff --git a/backend/src/services/ipsec-state.test.ts b/backend/src/services/ipsec-state.test.ts index 83e9cfb8..61eca7a5 100644 --- a/backend/src/services/ipsec-state.test.ts +++ b/backend/src/services/ipsec-state.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget } from "./ipsec-live.js" +import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget, ipsecClientBindingKeys } from "./ipsec-live.js" import { removeManagedIpsecServer } from "./ipsec-cleanup.js" import { deleteUserModeConfig, ensureIpsecPeer, identityRosBody, putOrPatchByName } from "./ipsec-ros.js" import { ipsecManagedComment, isIpsecManagedComment } from "./ipsec-config.js" @@ -244,4 +244,7 @@ assert.equal(mapClients(sanState)[0]?.online,false); checks++ sanState.certs.pop() sanState.identities=[] assert.equal(mapClients(sanState)[0]?.online,false); checks++ +assert.deepEqual(ipsecClientBindingKeys(state), mapClients(state).map(client => client.commonName ?? client.name)); checks++ +assert(ipsecClientBindingKeys(state).includes("PSK"), "Managed PSK selector survives the keys-only projection"); checks++ +assert.deepEqual(ipsecClientBindingKeys(sessionState), mapClients(sessionState).map(client => client.commonName ?? client.name)); checks++ console.log(`ipsec-state: ${checks} checks passed`) diff --git a/backend/src/services/recursive-route-catalog.test.ts b/backend/src/services/recursive-route-catalog.test.ts index 39054113..0a2c2761 100644 --- a/backend/src/services/recursive-route-catalog.test.ts +++ b/backend/src/services/recursive-route-catalog.test.ts @@ -14,6 +14,10 @@ assert.equal(parseRecursiveCatalog([{...row,disabled:"true"}])[0].disabled,true) const gateway=(id:string,active?:string)=>({".id":id,gateway:"192.0.2.1%ether1",active}) assert.equal(parseRecursiveGatewayCatalog([gateway("a")])[0].status,"unknown") assert.equal(parseRecursiveGatewayCatalog([gateway("a","false")])[0].status,"inactive") +assert.equal(parseRecursiveGatewayCatalog([{...gateway("a"),inactive:"true"}])[0].status,"inactive") +assert.equal(parseRecursiveGatewayCatalog([{...gateway("a"),inactive:true}])[0].status,"inactive") +assert.equal(parseRecursiveGatewayCatalog([{...gateway("a"),inactive:"false"}])[0].status,"unknown") +assert.equal(parseRecursiveGatewayCatalog([{...gateway("a","true"),inactive:"true"}])[0].status,"unknown") assert.equal(parseRecursiveGatewayCatalog([gateway("a","false"),gateway("b")])[0].status,"unknown") assert.equal(parseRecursiveGatewayCatalog([gateway("a"),gateway("b","true")])[0].status,"active") assert.deepEqual(parseRecursiveGatewayCatalog([{".id":"a",gateway:"999.0.0.1"}]),[]) diff --git a/backend/src/services/recursive-route-catalog.ts b/backend/src/services/recursive-route-catalog.ts index ea82268e..c12a6928 100644 --- a/backend/src/services/recursive-route-catalog.ts +++ b/backend/src/services/recursive-route-catalog.ts @@ -75,8 +75,12 @@ export function parseRecursiveGatewayCatalog(payload: unknown): RecursiveGateway if (typeof row.gateway !== "string") continue const [ip, name = ip] = row.gateway.trim().split("%") if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip) || ip.split(".").some(part => Number(part) > 255) || !name) continue - const id = ip + "%" + name, active = observedBoolean(rosText(row.active)) - const status = active === true ? "active" : active === false ? "inactive" : "unknown" + const id = ip + "%" + name + const active = typeof row.active === "boolean" ? row.active : observedBoolean(rosText(row.active)) + const inactive = typeof row.inactive === "boolean" ? row.inactive : observedBoolean(rosText(row.inactive)) + let status: RecursiveGatewayObservation["status"] = "unknown" + if (active === true && inactive !== true) status = "active" + if (active !== true && (active === false || inactive === true)) status = "inactive" const previous = result.get(id) result.set(id, { id, ip, name, status: previous?.status === "active" || status === "active" ? "active" : previous?.status === "unknown" || status === "unknown" ? "unknown" : "inactive" }) } diff --git a/backend/src/services/router-logs-query.ts b/backend/src/services/router-logs-query.ts index f042dac4..0d8a8184 100644 --- a/backend/src/services/router-logs-query.ts +++ b/backend/src/services/router-logs-query.ts @@ -58,11 +58,11 @@ export async function readLogContext(id: string) { const after = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,router_log_entries.id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows return { entry, before: before.reverse(), after } } -export async function readLogStream(filters: LogFilters, cursor: string) { +export async function readLogStream(filters: LogFilters, cursor: string, limit = 200) { // A committed upper bound is safe per device: writers are serialized by its advisory lock. // Advance across non-matching events too, so a quiet filtered stream never rescans its backlog. const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor]) const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark]) - const entries = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY router_log_entries.id LIMIT 200`, where.values)).rows - return { entries, cursor: entries.length === 200 ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled } + const entries = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY router_log_entries.id LIMIT $${where.values.length + 1}`, [...where.values, limit])).rows + return { entries, cursor: entries.length === limit ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled } } diff --git a/backend/src/services/routeros-snapshot-read.test.ts b/backend/src/services/routeros-snapshot-read.test.ts new file mode 100644 index 00000000..0fb8a859 --- /dev/null +++ b/backend/src/services/routeros-snapshot-read.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict" +import { createServer, type RequestListener } from "node:http" +import { test } from "node:test" +import { MikrotikClient, isRouterOsPreHeaderGetReset } from "./mikrotik.js" +import { readRouterOsSnapshotGet } from "./routeros-snapshot-read.js" + +async function loopback(work: (client: MikrotikClient, received: string[]) => Promise, respond: RequestListener) { + const received: string[] = [] + const server = createServer((request, response) => { + assert.equal(request.method, "GET") + received.push(request.url!) + respond(request, response) + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + const address = server.address(); assert(address && typeof address !== "string") + const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" }) + try { await work(client, received) } + finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) } +} + +async function brandedReset(): Promise { + let error: unknown + await loopback(async client => { + try { await client.get("/interface/ethernet") } catch (failure) { error = failure } + }, request => request.destroy()) + assert(error instanceof Error && isRouterOsPreHeaderGetReset(error)) + return error +} + +test("actual pre-header reset retries the same GET once with fresh source/authority admission", async () => { + let requests = 0, guards = 0 + await loopback(async (client, received) => { + assert.deepEqual(await readRouterOsSnapshotGet({ client, path: "/interface/ethernet", assertCurrent: async () => { guards++ } }), [{ ".id": "*1", name: "ether1" }]) + assert.deepEqual(received, ["/rest/interface/ethernet", "/rest/interface/ethernet"]) + assert.equal(guards, 4) + }, (request, response) => { + if (++requests === 1) request.destroy() + else response.end(JSON.stringify([{ ".id": "*1", name: "ether1" }])) + }) +}) + +test("repeated actual resets stop at two GETs and preserve the final transport error", async () => { + await loopback(async (client, received) => { + const failures: unknown[] = [] + const wrapped = { async get(...args: Parameters) { + try { return await client.get(...args) } catch (error) { failures.push(error); throw error } + } } + await assert.rejects(readRouterOsSnapshotGet({ client: wrapped, path: "/interface/ethernet", assertCurrent: async () => {} }), error => error === failures[1]) + assert.equal(failures.length, 2); assert.equal(received.length, 2) + assert(failures.every(isRouterOsPreHeaderGetReset)) + }, request => request.destroy()) +}) + +for (const outcome of ["headers-reset", "information-reset", "body-reset", "429", "bad-json", "timeout", "abort"] as const) { + test(`${outcome} cannot spend a retry slot`, async () => { + const abort = new AbortController() + await loopback(async (client, received) => { + let failure: unknown + try { await readRouterOsSnapshotGet({ client, path: "/interface/ethernet", timeoutMs: 80, signal: abort.signal, assertCurrent: async () => {} }) } + catch (error) { failure = error } + assert(failure instanceof Error) + assert.equal(isRouterOsPreHeaderGetReset(failure), false) + assert.equal(received.length, 1) + }, (request, response) => { + if (outcome === "timeout") return + if (outcome === "abort") { abort.abort(new Error("caller cancelled")); return } + if (outcome === "information-reset") { response.writeContinue(); setTimeout(() => request.destroy(), 10); return } + if (outcome === "headers-reset" || outcome === "body-reset") { + response.writeHead(200, { "content-type": "application/json", "content-length": "1000" }); response.flushHeaders() + if (outcome === "body-reset") response.write("[") + setTimeout(() => response.destroy(), 10); return + } + if (outcome === "429") { response.writeHead(429); response.end("{}"); return } + response.end("!") + }) + }) +} + +test("spoofed reset, pre-abort and initial authority denial dispatch at most one or zero GETs", async () => { + const spoof = Object.assign(new Error("ECONNRESET"), { code: "ECONNRESET" }) + let calls = 0 + const client = { async get(): Promise { calls++; throw spoof } } + await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", assertCurrent: async () => {} }), error => error === spoof) + assert.equal(calls, 1) + const abort = new AbortController(); const cancelled = new Error("cancelled"); abort.abort(cancelled) + await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", signal: abort.signal, assertCurrent: async () => {} }), error => error === cancelled) + const denied = new Error("authority denied") + await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", assertCurrent: async () => { throw denied } }), error => error === denied) + assert.equal(calls, 1) +}) + +test("both attempts share one decreasing budget including admission work", async () => { + const reset = await brandedReset() + let clock = 0, guards = 0, dispatches = 0 + const timeouts: number[] = [] + const client = { async get(_path: string, timeout?: number, _signal?: AbortSignal, _maxBytes?: number, beforeDispatch?: () => Promise): Promise { + timeouts.push(timeout!) + await beforeDispatch!() + dispatches++ + if (dispatches === 1) { clock += 20; throw reset } + return [] as T + } } + await readRouterOsSnapshotGet({ client, path: "/interface", timeoutMs: 100, now: () => clock, assertCurrent: async () => { guards++; clock += 10 } }) + assert.deepEqual(timeouts, [90, 50]); assert.equal(guards, 4); assert.equal(dispatches, 2) +}) + +test("authority/source revocation and expiry between attempts forbid the second GET", async () => { + const reset = await brandedReset() + for (const outcome of ["authority", "source", "expiry"] as const) { + let clock = 0, guards = 0, calls = 0 + const denied = new Error(outcome + " changed") + const client = { async get(_path: string, _timeout?: number, _signal?: AbortSignal, _maxBytes?: number, beforeDispatch?: () => Promise): Promise { + await beforeDispatch!(); calls++; throw reset + } } + await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", timeoutMs: 100, now: () => clock, assertCurrent: async () => { + if (++guards === 3) { if (outcome === "expiry") clock = 100; else throw denied } + } }), error => error === (outcome === "expiry" ? reset : denied)) + assert.equal(calls, 1); assert.equal(guards, 3) + } +}) + +test("deadline expiry during the final dispatch admission starts no HTTP request", async () => { + let clock = 0, guards = 0 + await loopback(async (client, received) => { + await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", timeoutMs: 100, now: () => clock, assertCurrent: async () => { + if (++guards === 2) clock = 100 + } }), { name: "TimeoutError" }) + assert.equal(received.length, 0) + }, (_request, response) => response.end("[]")) +}) diff --git a/backend/src/services/routeros-snapshot-read.ts b/backend/src/services/routeros-snapshot-read.ts new file mode 100644 index 00000000..5dff0c18 --- /dev/null +++ b/backend/src/services/routeros-snapshot-read.ts @@ -0,0 +1,49 @@ +import { isRouterOsPreHeaderGetReset } from "./mikrotik.js" + +interface SnapshotGetClient { + get(path: string, timeoutMs?: number, signal?: AbortSignal, maxResponseBytes?: number, beforeDispatch?: () => Promise): Promise +} + +/** A single catalogue read, pinned to the original client/path. Only a transport- + * branded reset before any response headers can spend its one retry slot. */ +export async function readRouterOsSnapshotGet(input: { + client: SnapshotGetClient + path: string + assertCurrent(): Promise + timeoutMs?: number + signal?: AbortSignal + now?: () => number +}): Promise { + const { client, path, assertCurrent, signal } = input + const timeoutMs = input.timeoutMs ?? 10_000, now = input.now ?? (() => performance.now()) + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) throw new RangeError("Invalid RouterOS snapshot read timeout") + const deadline = now() + timeoutMs + let originalReset: unknown + const remaining = () => { + signal?.throwIfAborted() + const budget = deadline - now() + if (budget <= 0) throw originalReset ?? new DOMException("RouterOS snapshot read deadline expired", "TimeoutError") + return Math.ceil(budget) + } + for (let attempt = 0; attempt < 2; attempt++) { + remaining() + await assertCurrent() + const budget = remaining() + const deadlineSignal = AbortSignal.timeout(budget) + const requestSignal = signal ? AbortSignal.any([signal, deadlineSignal]) : deadlineSignal + try { + const result = await client.get(path, budget, requestSignal, undefined, async () => { + await assertCurrent() + remaining() + requestSignal.throwIfAborted() + }) + remaining() + return result + } catch (error) { + if (attempt !== 0 || !isRouterOsPreHeaderGetReset(error) || signal?.aborted) throw error + originalReset = error + remaining() + } + } + throw originalReset +} diff --git a/backend/src/services/vrf-execution-diagnostic.test.mjs b/backend/src/services/vrf-execution-diagnostic.test.mjs new file mode 100644 index 00000000..54c24b82 --- /dev/null +++ b/backend/src/services/vrf-execution-diagnostic.test.mjs @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict' +import {readFile} from 'node:fs/promises' +import ts from 'typescript' + +// Execute the real emitted executor body with only its imported I/O dependencies +// substituted. No SQL, HTTP, config files or live authority can be contacted. +const compile=async name=>ts.transpileModule(await readFile(new URL(name,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText +const uri=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64') +// Preserve offline I/O substitution while linking pure relative leaf modules. +const pureModules=new Map() +async function pureModuleUri(name){ + if(pureModules.has(name))return pureModules.get(name) + const loading=(async()=>{ + let code=await compile(name) + const ast=ts.createSourceFile(name,code,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS) + const imports=ast.statements.filter(ts.isImportDeclaration).filter(node=>node.moduleSpecifier.text.startsWith('./')) + const edits=await Promise.all(imports.map(async node=>({start:node.moduleSpecifier.getStart(ast),end:node.moduleSpecifier.end,value:await pureModuleUri(node.moduleSpecifier.text.replace(/\.js$/,'.ts'))}))) + for(const edit of edits.reverse())code=code.slice(0,edit.start)+JSON.stringify(edit.value)+code.slice(edit.end) + return uri(code) + })() + pureModules.set(name,loading);return loading +} +const diagnosticUri=await pureModuleUri('./vrf-execution-diagnostic.ts') +const diagnostic=await import(diagnosticUri) +const id='12345678-1234-4123-8123-123456789abc' +let cases=0 +for(const [error,expected]of [[Error('SECRET'),'Error'],[new TypeError('SECRET'),'TypeError'],[new RangeError('SECRET'),'RangeError'],[new SyntaxError('SECRET'),'SyntaxError'],[new ReferenceError('SECRET'),'ReferenceError'],[new URIError('SECRET'),'URIError'],[new EvalError('SECRET'),'EvalError'],[{message:'SECRET'},'NonError'],[new Proxy(Error('SECRET'),{get(){throw Error('getter')}}),'NonError']]){ + const value=diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',error,false,{serverId:1,catalog:'vrf',boundary:'native-get'}) + assert.equal(value.errorClass,expected);assert.equal(JSON.stringify(value).includes('SECRET'),false);cases++ +} +const trap=Error('SECRET') +for(const key of ['message','stack','code','cause'])Object.defineProperty(trap,key,{get(){throw Error('SECRET getter')}}) +assert.equal(diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',trap,false).category,'other');cases++ +for(const code of ['ECONNRESET','ETIMEDOUT','40P01','SECRET']){ + const value=diagnostic.vrfExecutionDiagnostic(id,'delete','ack-journal',Object.assign(Error('SECRET'),{code}),true) + assert.equal(value.category,({ECONNRESET:'reset',ETIMEDOUT:'timeout','40P01':'pg-deadlock'})[code]??'other') + assert.equal(JSON.stringify(value).includes('SECRET'),false);cases++ +} +assert.equal(diagnostic.vrfExecutionDiagnostic('SECRET','create','raw-before',Error(),false),undefined) +assert.equal(diagnostic.vrfExecutionDiagnostic(id,'SECRET','raw-before',Error(),false),undefined) +assert.equal(diagnostic.vrfExecutionDiagnostic(id,'create','SECRET',Error(),false),undefined) +assert.equal(diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',Error(),false,{serverId:1,catalog:'SECRET',boundary:'native-get'}).read,undefined);cases++ +diagnostic.notifyVrfDiagnostic(()=>{throw Error('observer')},{}) +diagnostic.notifyVrfDiagnostic(async()=>{throw Error('observer')},{}) +await new Promise(resolve=>setImmediate(resolve));cases++ + +const key='__vrfDiagnosticOfflineFixture' +class McpError extends Error{constructor(code,message){super(message);this.code=code}} +globalThis[key]={McpError} +const functions=['readVrfProductionSnapshot','claimStoredVrfProductionTicket','inspectVrfProductionAdmission','assertVrfProductionHistoryProvenance','assertVrfProductionTransitionCheckpoint','classifyVrfProductionTransition','openVrfProductionTransitionCheckpoint','sealVrfProductionTransitionCheckpoint','vrfProductionCheckpointCatalogs','assertVerifiedConfigurationAccess','withVrfClaimQuarantine','assertVrfActionContext','vrfNativeActionFor','vrfActionContractsCurrent','findMcpOperation','decryptSecretExisting','encryptSecret','fingerprint','withVrfProductionSealingKey','configurationRows','resourceById','routerClient','rosSnapshotSourceProof'] +const stubUri=uri(`export const McpError=globalThis.${key}.McpError;export const pool={query:(...args)=>globalThis.${key}.query(...args)};`+functions.map(name=>`export const ${name}=(...args)=>globalThis.${key}.${name}(...args);`).join('\n')) +let emitted=await compile('./vrf-production-execution.ts') +const ast=ts.createSourceFile('executor.js',emitted,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS) +const edits=ast.statements.filter(ts.isImportDeclaration).map(node=>({start:node.moduleSpecifier.getStart(ast),end:node.moduleSpecifier.end,value:node.moduleSpecifier.text.endsWith('/vrf-execution-diagnostic.js')?diagnosticUri:stubUri})) +for(const edit of edits.reverse())emitted=emitted.slice(0,edit.start)+JSON.stringify(edit.value)+emitted.slice(edit.end) +const api=await import(uri(emitted)) +function fixture(options={}){ + const failure=Object.assign(Error('PRIVATE_PASSWORD native-body SQL /private/path'),{code:options.errno??'ETIMEDOUT'}) + const events=[],calls=[];let reads=0,deps=0,access=0,inspect=0,routers=0,guard,clientCalls=0 + const proof='a'.repeat(64),snapshot={projection:'raw-native-v1',serverId:1,sourceProof:proof,catalogs:{vrf:[{'.id':'*0',name:'main',interfaces:'all',disabled:'false'}],interface_inventory:[]}} + const fail=name=>{calls.push(name);if(options.fail===name)throw failure} + const f={McpError, + async query(sql){ + if(sql.includes('SELECT cp.after_sealed')){fail('history-load');return{rows:[]}} + if(sql.includes('SELECT ticket_id')){fail('ticket-lookup');return{rows:[{ticket_id:'ticket'}]}} + if(sql.includes('SET before_sealed'))fail('checkpoint-before') + if(sql.includes("SET state='applied'"))fail('ack-journal') + if(sql.includes('SET after_sealed'))fail('checkpoint-after') + return{rows:[{id,operation_id:id}]} + }, + async assertVerifiedConfigurationAccess(){fail('access-'+(++access))}, + async routerClient(){fail('router-'+(++routers));return{server:{},client:{ + async put(){clientCalls++;fail('client-before-guard');await guard();fail('client-after-guard');return options.badAck?{}:{'.id':'*1'}}, + async patch(){clientCalls++;await guard();return{'.id':'*1'}}, + async delete(_path,_timeout,_before,onBody){clientCalls++;await guard();onBody(options.badDeleteBody?'PRIVATE_INVALID_JSON':'{}')}, + }}}, + async rosSnapshotSourceProof(){return proof}, + vrfNativeActionFor(){return'ros.vrf.create'},assertVrfActionContext(){},vrfActionContractsCurrent(){return true},findMcpOperation(){return{version:1}}, + async withVrfProductionSealingKey(work){fail('sealing-key');return work(Buffer.alloc(32))}, + async readVrfProductionSnapshot(input){reads++;input.observeRead?.({serverId:1,catalog:'gre',boundary:'native-get'});fail('read-'+reads);return structuredClone(snapshot)}, + configurationRows(_spec,rows){return rows},resourceById(){return{}},fingerprint(){return'current-fingerprint'}, + async withVrfClaimQuarantine(input){return input.claim()},async claimStoredVrfProductionTicket(){fail('ticket-claim');return{privateSealed:'PRIVATE_SEALED'}}, + async encryptSecret(){return'PRIVATE_CIPHERTEXT'},async decryptSecretExisting(){throw Error('not used')}, + inspectVrfProductionAdmission(){fail('inspect-'+(++inspect));return{admission:{}}}, + assertVrfProductionHistoryProvenance(){},assertVrfProductionTransitionCheckpoint(){},openVrfProductionTransitionCheckpoint(){},vrfProductionCheckpointCatalogs(){return[]}, + classifyVrfProductionTransition(){fail('classification');return{checkpoint:{}}},sealVrfProductionTransitionCheckpoint(){return'PRIVATE_HISTORY'}, + } + globalThis[key]=f + const input={operationId:id,planId:'plan',connectionId:'connection',ownerId:'owner',operationName:options.action==='delete'?'ros.vrf.delete':'ros.vrf.create',operationVersion:1,stepIndex:0, + step:{serverId:1,resource:'vrf',action:options.action??'create',...(options.action==='delete'?{id:'*1'}:{}),fields:options.action==='delete'?{}:{name:'customers',interfaces:'none'},placeBeforeId:options.action==='delete'?undefined:'*0'},sourceProof:proof, + originalSnapshots:options.originalDrift?[{serverId:1,resource:'vrf',sourceProof:proof,fingerprint:'old-fingerprint'}]:[], + async authorize(){},async assertDependencies(){fail('dependencies-'+(++deps))},installBeforeDispatch(value){fail('install');guard=value}, + onDiagnostic(value){events.push(value);if(options.observerThrows)throw Error('logging failed');if(options.observerRejects)return Promise.reject(Error('async logging failed'))}, + } + return{failure,events,calls,input,clientCalls:()=>clientCalls,run:()=>api.executeVrfProductionStep(input)} +} +for(const [fail,stage,ack]of [ + ['access-1','initial-authorization',false],['sealing-key','sealing-key',false],['read-1','raw-before',false], + ['ticket-lookup','ticket-lookup',false],['ticket-claim','ticket-claim',false],['checkpoint-before','checkpoint-before',false],['install','install-predispatch',false], + ['access-2','explicit-predispatch-authorization',false],['dependencies-1','explicit-predispatch-dependencies',false],['read-2','explicit-predispatch-read',false],['inspect-1','explicit-predispatch-admission',false],['access-3','explicit-predispatch-final-authorization',false], + ['router-4','dispatch-source',false],['client-before-guard','client-call',false],['access-4','installed-predispatch-authorization',false],['dependencies-2','installed-predispatch-dependencies',false],['read-3','installed-predispatch-read',false],['inspect-2','installed-predispatch-admission',false],['access-5','installed-predispatch-final-authorization',false],['client-after-guard','client-call',false], + ['ack-journal','ack-journal',true],['access-6','post-ack-authorization',true],['read-4','raw-after',true],['classification','transition-classification',true],['dependencies-3','post-ack-dependencies',true],['access-7','post-ack-final-authorization',true],['checkpoint-after','checkpoint-after',true], +]){ + const f=fixture({fail});let thrown;try{await f.run()}catch(error){thrown=error} + assert.ok(thrown,fail);assert.equal(f.events.length,1,fail);assert.equal(f.events[0].stage,stage,fail);assert.equal(f.events[0].executorAckObserved,ack,fail) + assert.equal(f.events[0].category,'timeout',fail) + if(fail.startsWith('read-'))assert.deepEqual(f.events[0].read,{serverId:1,catalog:'gre',boundary:'native-get'},fail) + else assert.equal(f.events[0].read,undefined,fail) + if(!ack)assert.equal(thrown,f.failure,fail) + else assert.ok(thrown instanceof api.VrfProductionAcknowledgedFailure,fail) + if(fail==='ack-journal')assert.equal(thrown.code,'VRF_ACK_JOURNAL_UNCONFIRMED') + if(fail==='checkpoint-after')assert.equal(thrown.code,'VRF_VERIFIED_CHECKPOINT_UNCONFIRMED') + assert.equal(JSON.stringify(f.events).includes('PRIVATE'),false);cases++ +} +for(const observer of ['observerThrows','observerRejects']){ + const f=fixture({fail:'read-1',[observer]:true});await assert.rejects(f.run(),error=>error===f.failure) + await new Promise(resolve=>setImmediate(resolve));assert.equal(f.events.length,1);cases++ +} +const original=fixture({originalDrift:true});await assert.rejects(original.run(),error=>error.code==='STATE_CONFLICT');assert.equal(original.events[0].stage,'original-dependencies');cases++ +const history=fixture({action:'delete',fail:'history-load'});await assert.rejects(history.run(),error=>error===history.failure);assert.equal(history.events[0].stage,'history-load');cases++ +const malformed=fixture({badAck:true});await assert.rejects(malformed.run(),error=>error.code==='STATE_CONFLICT'&&!(error instanceof api.VrfProductionAcknowledgedFailure));assert.equal(malformed.events[0].stage,'ack-identity');assert.equal(malformed.events[0].executorAckObserved,true);cases++ +const malformedDelete=fixture({action:'delete',badDeleteBody:true});await assert.rejects(malformedDelete.run(),error=>error instanceof api.VrfProductionAcknowledgedFailure&&error.code==='VERIFICATION_FAILED');assert.equal(malformedDelete.events[0].executorAckObserved,true);assert.equal(malformedDelete.events[0].stage,'ack-identity');cases++ +const happy=fixture();const receipt=await happy.run();assert.equal(receipt.configurationVerified,true);assert.equal(happy.events.length,0);assert.equal(happy.clientCalls(),1);cases++ +const silent=fixture({fail:'read-1'});delete silent.input.onDiagnostic;await assert.rejects(silent.run(),error=>error===silent.failure);cases++ +// Exercise the exact emitted runtime policy call/callback/fallback/clear wiring. +// Only its surrounding policy and executor I/O are synthetic. Extracting the +// live AST statements means this test fails if the runtime stops wiring them. +const runtimeSource=await compile('../modules/mcp/runtime.ts') +const runtimeAst=ts.createSourceFile('runtime.js',runtimeSource,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS) +const setup=[],clear=[],policyCalls=[],fallback=[] +function visit(node){ + if(ts.isExpressionStatement(node)){ + const text=node.getText(runtimeAst) + if(text.startsWith('vrfDiagnosticFallback = productionVrf'))setup.push(text) + if(text==='vrfDiagnosticFallback = undefined;')clear.push(text) + if(text==='vrfDiagnosticFallback?.failure(error);')fallback.push(text) + } + if(ts.isVariableStatement(node)&&node.declarationList.declarations.length===1){ + const value=node.declarationList.declarations[0] + if(value.name.getText(runtimeAst)==='result'&&value.initializer&&ts.isAwaitExpression(value.initializer)&&ts.isCallExpression(value.initializer.expression)&&value.initializer.expression.expression.getText(runtimeAst)==='withVerifiedConfigurationChange'&&node.getText(runtimeAst).includes('executeVrfProductionStep'))policyCalls.push(node.getText(runtimeAst)) + } + ts.forEachChild(node,visit) +} +visit(runtimeAst) +for(const statements of [setup,clear,policyCalls,fallback])assert.equal(statements.length,1) +const runtimeBoundary=new Function(`return async function(env){ + const {productionVrf,step,plan,operationId,connection,serverIds,evidence,serviceExecution,reservedStep,authorizeCatalog,authorize,prefix,McpError,executeVrfProductionStep,executeRosStep,withVerifiedConfigurationChange,createVrfRuntimeDiagnosticBoundary,console}=env; + const index=0;let vrfDiagnosticFallback,vrfBeforeDispatch; + try{${setup[0]}\n${policyCalls[0]}\n${clear[0]}\nif(env.failAfterWrapper)throw env.failure;return result} + catch(error){${fallback[0]}\nthrow error} +}`)() +for(const mode of ['runtime-policy-entry','policy-authorize','executor-entry','executor-clone','executor-binding','executor-reported','runtime-policy-exit','after-wrapper','non-production','success']){ + for(const loggerThrows of [false,true]){ + const error=Object.assign(Error('PRIVATE_RUNTIME_POLICY_FAILURE'),{code:'ECONNRESET'}),events=[] + const env={failure:error,failAfterWrapper:mode==='after-wrapper',productionVrf:mode!=='non-production', + operationId:id,connection:{owner_id:'owner',id:'connection'},serverIds:[1],evidence:{objects:[],snapshots:[{resource:'vrf',serverId:1,sourceProof:'a'.repeat(64)}]},serviceExecution:undefined,reservedStep:{}, + plan:{id:'plan',operation_id:'ros.vrf.create',operation_version:1,approved_by:'owner'},step:{kind:'ros',step:{serverId:1,resource:'vrf',action:'create',fields:{name:'customers',interfaces:'none'}}}, + prefix:{async check(){}},async authorize(){},async authorizeCatalog(){if(mode==='policy-authorize')throw error},McpError,createVrfRuntimeDiagnosticBoundary:diagnostic.createVrfRuntimeDiagnosticBoundary, + console:{error(raw){events.push(JSON.parse(raw));if(loggerThrows)throw Error('PRIVATE_LOGGER_FAILURE')}}, + async executeRosStep(){throw error}, + async executeVrfProductionStep(input){ + if(mode==='executor-entry')throw error + if(mode==='executor-clone'){input.step.fields.uncloneable=()=>{};return api.executeVrfProductionStep(input)} + if(mode==='executor-binding'){fixture();globalThis[key].assertVrfActionContext=()=>{throw error};return api.executeVrfProductionStep(input)} + if(mode==='executor-reported'){input.onDiagnostic(diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',error,false,{serverId:1,catalog:'vrf',boundary:'native-get'}));throw error} + return{kind:'vrf-production-execution-v1',configurationVerified:true,nativeId:'*1'} + }, + async withVerifiedConfigurationChange(context,work){ + if(mode==='runtime-policy-entry')throw error + if(mode==='policy-authorize')await context.authorize() + const receipt=await work();if(mode==='runtime-policy-exit')throw error;return receipt + }, + } + if(mode==='success'){assert.equal((await runtimeBoundary(env)).configurationVerified,true);assert.equal(events.length,0)} + else{ + await assert.rejects(runtimeBoundary(env),value=>mode==='executor-clone'?value.name==='DataCloneError':value===error) + if(['after-wrapper','non-production'].includes(mode))assert.equal(events.length,0) + else{assert.equal(events.length,1);assert.equal(events[0].stage,mode==='executor-reported'?'raw-before':['executor-clone','executor-binding'].includes(mode)?'executor-entry':mode==='policy-authorize'?'runtime-policy-entry':mode);assert.equal(events[0].executorAckObserved,mode==='runtime-policy-exit');assert.equal(events[0].category,mode==='executor-clone'?'other':'reset')} + } + assert.equal(JSON.stringify(events).includes('PRIVATE'),false);cases++ + } +} +delete globalThis[key] +console.log('VRF private execution diagnostics, actual executor body with offline imported I/O, closed projection cases:',cases) diff --git a/backend/src/services/vrf-execution-diagnostic.ts b/backend/src/services/vrf-execution-diagnostic.ts new file mode 100644 index 00000000..4f4dfa7e --- /dev/null +++ b/backend/src/services/vrf-execution-diagnostic.ts @@ -0,0 +1,74 @@ +import { types } from "node:util" +import { readVrfTransitionRefusal, type VrfTransitionRefusalTag } from "./vrf-transition-refusal.js" + +export const VRF_EXECUTION_STAGES = [ + "runtime-policy-entry", "executor-entry", "runtime-policy-exit", + "initial-authorization", "sealing-key", "history-load", "raw-before", "original-dependencies", "history-provenance", "supported-shape", "ticket-lookup", "ticket-claim", "checkpoint-before", "install-predispatch", + "explicit-predispatch-authorization", "explicit-predispatch-dependencies", "explicit-predispatch-read", "explicit-predispatch-admission", "explicit-predispatch-final-authorization", + "dispatch-source", "client-call", "installed-predispatch-authorization", "installed-predispatch-dependencies", "installed-predispatch-read", "installed-predispatch-admission", "installed-predispatch-final-authorization", + "ack-identity", "ack-journal", "post-ack-authorization", "raw-after", "transition-classification", "post-ack-dependencies", "post-ack-final-authorization", "checkpoint-after", +] as const +export type VrfExecutionStage = typeof VRF_EXECUTION_STAGES[number] +export const VRF_READ_BOUNDARIES = ["authority-before", "native-get", "authority-after", "shape", "snapshot-validation", "final-authority"] as const +export interface VrfRawReadObservation { serverId: number; catalog?: string; boundary: typeof VRF_READ_BOUNDARIES[number] } +const catalogs = new Set(["bridge", "bridge_port", "bridge_vlan", "vrf", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route", "routing_table", "ethernet", "ip_filter", "ipv6_filter", "routing_rule", "ip_settings", "ipv6_settings", "interface_inventory", "interface_list_members", "vlan", "bonding", "wireguard", "wireguard_peer", "gre", "ipip", "eoip", "vxlan", "vxlan_vtep", "pppoe_client"]) +const classes = [[Error.prototype, "Error"], [TypeError.prototype, "TypeError"], [RangeError.prototype, "RangeError"], [SyntaxError.prototype, "SyntaxError"], [ReferenceError.prototype, "ReferenceError"], [URIError.prototype, "URIError"], [EvalError.prototype, "EvalError"]] as const +const categories = Object.freeze({ ECONNRESET: "reset", ECONNREFUSED: "refused", ETIMEDOUT: "timeout", ENOTFOUND: "dns", EAI_AGAIN: "dns", EPIPE: "pipe", ABORT_ERR: "aborted", ERR_STREAM_DESTROYED: "stream", "40001": "pg-serialization", "40P01": "pg-deadlock", "57014": "pg-cancelled", "08003": "pg-connection", "08006": "pg-connection", "57P01": "pg-connection" } as const) +export interface VrfExecutionDiagnostic { + event: "mcp.vrf.execution-failed" + operationId: string + action: "create" | "patch" | "delete" + stage: VrfExecutionStage + errorClass: "Error" | "TypeError" | "RangeError" | "SyntaxError" | "ReferenceError" | "URIError" | "EvalError" | "OtherNativeError" | "NonError" + category: "other" | typeof categories[keyof typeof categories] + /** Executor response observation only, never write-fence/dispatch proof. */ + executorAckObserved: boolean + read?: VrfRawReadObservation + transition?: VrfTransitionRefusalTag +} +/** Closed private diagnostic only. Never read error messages, stacks, getters, + * causes, request bodies, SQL or native values. No outcome/authority is minted. */ +export function vrfExecutionDiagnostic(operationId: string, action: string, stage: VrfExecutionStage, error: unknown, executorAckObserved: boolean, read?: VrfRawReadObservation): VrfExecutionDiagnostic | undefined { + if (typeof operationId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(operationId) || !["create", "patch", "delete"].includes(action) || !VRF_EXECUTION_STAGES.includes(stage) || typeof executorAckObserved !== "boolean") return undefined + const result: VrfExecutionDiagnostic = { event: "mcp.vrf.execution-failed", operationId, action: action as VrfExecutionDiagnostic["action"], stage, errorClass: "NonError", category: "other", executorAckObserved } + if (types.isNativeError(error)) { + result.errorClass = classes.find(([prototype]) => Object.getPrototypeOf(error) === prototype)?.[1] ?? "OtherNativeError" + const descriptor = Object.getOwnPropertyDescriptor(error, "code") + if (descriptor && Object.hasOwn(descriptor, "value") && typeof descriptor.value === "string" && Object.hasOwn(categories, descriptor.value)) result.category = categories[descriptor.value as keyof typeof categories] + } + if (read && Number.isSafeInteger(read.serverId) && read.serverId > 0 && VRF_READ_BOUNDARIES.includes(read.boundary) && (read.catalog === undefined || typeof read.catalog === "string" && catalogs.has(read.catalog))) result.read = { serverId: read.serverId, boundary: read.boundary, ...(read.catalog === undefined ? {} : { catalog: read.catalog }) } + const transition = readVrfTransitionRefusal(error) + if (transition) result.transition = transition + return result +} +/** Observers are in-process only and cannot fail, await, or replace the work. */ +export function notifyVrfDiagnostic(observer: ((value: T) => unknown) | undefined, value: T): void { + try { + if (!observer) return + const result = observer(value) + if (types.isPromise(result)) void Promise.prototype.then.call(result, undefined, () => {}) + } catch { /* Private observation never changes the original control flow. */ } +} +/** Private per-step fallback for failures before the executor's own try or in + * its surrounding policy context. Executor diagnostics always take precedence. + * Runtime discards this object immediately when the policy wrapper succeeds. */ +export function createVrfRuntimeDiagnosticBoundary(operationId: string, action: string, observer: (value: VrfExecutionDiagnostic) => unknown) { + let stage: VrfExecutionStage = "runtime-policy-entry", emitted = false, executorAckObserved = false + return { + executorEntered() { stage = "executor-entry" }, + executorReturned() { stage = "runtime-policy-exit"; executorAckObserved = true }, + observed(value: VrfExecutionDiagnostic) { + if (value.operationId !== operationId || value.action !== action || emitted) return + emitted = true + notifyVrfDiagnostic(observer, value) + }, + failure(error: unknown) { + if (emitted) return + emitted = true + try { + const value = vrfExecutionDiagnostic(operationId, action, stage, error, executorAckObserved) + if (value) notifyVrfDiagnostic(observer, value) + } catch { /* Fallback logging cannot replace the outer runtime error. */ } + }, + } +} diff --git a/backend/src/services/vrf-production-action-binding.test.ts b/backend/src/services/vrf-production-action-binding.test.ts index 585013cf..e954493b 100644 --- a/backend/src/services/vrf-production-action-binding.test.ts +++ b/backend/src/services/vrf-production-action-binding.test.ts @@ -3,11 +3,11 @@ import { assertVrfActionContext, dedupeVrfAliasSnapshots, vrfNativeActionFor, vr import type { VrfTicketContext } from "./vrf-production-ticket-store.js" import type { VrfProductionRequest } from "./vrf-production-admission.js" import type { RosSnapshot } from "../modules/mcp/resources.js" -const context = { actionId: "ros.vrf.update", actionVersion: 3, authorityAction: { id: "routing.vrf.update", version: 3 } } as VrfTicketContext +const context = { actionId: "ros.vrf.update", actionVersion: 4, authorityAction: { id: "routing.vrf.update", version: 4 } } as VrfTicketContext const request: VrfProductionRequest = { action: "patch", id: "*a", fields: { interfaces: "owned-a,owned-b" } } let cases = 0 assertVrfActionContext(context, request); cases++ -assert.deepEqual(vrfPublicAuthority(context), { id: "routing.vrf.update", version: 3 }); cases++ +assert.deepEqual(vrfPublicAuthority(context), { id: "routing.vrf.update", version: 4 }); cases++ assert.equal(vrfNativeActionFor("routing.vrf.update", request), "ros.vrf.update"); cases++ const invalidRequests: VrfProductionRequest[] = [ { ...request, action: "create" as const }, { ...request, action: "delete" as const }, { ...request, fields: { name: "other" } }, @@ -15,6 +15,7 @@ const invalidRequests: VrfProductionRequest[] = [ ] for (const changed of invalidRequests) { assert.throws(() => assertVrfActionContext(context, changed)); cases++ } assert.throws(() => assertVrfActionContext({ ...context, authorityAction: { id: "routing.vrf.update", version: 2 } } as unknown as VrfTicketContext, request)); cases++ +assert.throws(() => assertVrfActionContext({ ...context, authorityAction: { id: "routing.vrf.update", version: 3 } } as unknown as VrfTicketContext, request)); cases++ assert.throws(() => assertVrfActionContext({ ...context, actionId: "ros.vrf.delete" }, request)); cases++ const snapshot: RosSnapshot = { serverId: 1, resource: "vrf", rows: [{ ".id": "*a", interfaces: "owned-a" }], fingerprint: "a".repeat(64), sourceProof: "b".repeat(64), projectionVersion: 3 } assert.equal(dedupeVrfAliasSnapshots([snapshot, structuredClone(snapshot)]).length, 1); cases++ diff --git a/backend/src/services/vrf-production-action-binding.ts b/backend/src/services/vrf-production-action-binding.ts index 84f28303..f942fd79 100644 --- a/backend/src/services/vrf-production-action-binding.ts +++ b/backend/src/services/vrf-production-action-binding.ts @@ -11,7 +11,7 @@ export interface VrfActionContext { actionId: VrfNativeActionId actionVersion: number /** Actual public authority, never a canonical permission substitution. */ - authorityAction?: { id: "routing.vrf.update"; version: 3 } + authorityAction?: { id: "routing.vrf.update"; version: 4 } } const refuse = (reason: string): never => { throw Error("VRF action binding refused: " + reason) } export function vrfPublicAuthority(context: VrfActionContext): { id: VrfAuthorityActionId; version: number } { @@ -26,7 +26,7 @@ export function vrfNativeActionFor(publicId: string, request: VrfProductionReque export function assertVrfActionContext(context: VrfActionContext, request: VrfProductionRequest): void { const authority = vrfPublicAuthority(context) if (!Number.isSafeInteger(context.actionVersion) || context.actionVersion < 1 || vrfNativeActionFor(authority.id, request) !== context.actionId) refuse("native action/version") - if (context.authorityAction && (context.authorityAction.id !== "routing.vrf.update" || context.authorityAction.version !== 3 || request.action !== "patch" || !request.id || !/^\*[a-f0-9]+$/i.test(request.id) || request.placeBeforeId !== undefined || Object.keys(request.fields).length !== 1 || typeof request.fields.interfaces !== "string")) refuse("alias must be one exact interfaces PATCH") + if (context.authorityAction && (context.authorityAction.id !== "routing.vrf.update" || context.authorityAction.version !== 4 || request.action !== "patch" || !request.id || !/^\*[a-f0-9]+$/i.test(request.id) || request.placeBeforeId !== undefined || Object.keys(request.fields).length !== 1 || typeof request.fields.interfaces !== "string")) refuse("alias must be one exact interfaces PATCH") } /** Registry versions are protocol fences, not permission grants. */ export function vrfActionContractsCurrent(context: VrfActionContext): boolean { diff --git a/backend/src/services/vrf-production-admission.test.ts b/backend/src/services/vrf-production-admission.test.ts index 3f1bc111..88652ed2 100644 --- a/backend/src/services/vrf-production-admission.test.ts +++ b/backend/src/services/vrf-production-admission.test.ts @@ -22,7 +22,7 @@ const request: VrfProductionRequest = { action: "patch", id: "*1", fields: { nam const issued = sealVrfProductionAdmission({ snapshot: snapshot(), binding, request, secret, now }) const input = () => ({ sealed: issued.sealed, snapshot: snapshot(), binding: structuredClone(binding), request: structuredClone(request), secret, now: now + 1 }) const read = inspectVrfProductionAdmission(input()) -assert.equal(read.admission.version, 2) +assert.equal(read.admission.version, 3) assert.equal(read.admission.companionExecution, "requires-native-transition-proof") assert.equal(read.admission.members[0].id, "*11") assert.deepEqual(read.admission.members[0].parents.map(row => [row.id, row.configurationId]), [["*11", "*21"], ["*10", "*20"]]) @@ -68,11 +68,10 @@ for (const [resource, field] of [["interface_inventory", "rx-byte"], ["ipv4_rout const fresh = input(); fresh.snapshot.catalogs[resource][0][field] = "changed-live-telemetry" assert.equal(inspectVrfProductionAdmission(fresh).digest, issued.digest) } -// Old globally projected admissions cannot be replayed under v2 even with an -// otherwise correct MAC/key. Both the private signature domain and schema -// version changed; no durable SQL migration or public tool version is needed. +// Old admissions cannot be replayed under v3 even with the correct key. +// Private schema/domains and the four VRF operation versions changed together. const oldPayload = Buffer.from(JSON.stringify({ ...issued.admission, version: 1 })).toString("base64url") -for (const domain of ["mmapp-vrf-production-admission-v1\0", "mmapp-vrf-production-admission-v2\0"]) { +for (const domain of ["mmapp-vrf-production-admission-v1\0", "mmapp-vrf-production-admission-v2\0", "mmapp-vrf-production-admission-v3\0"]) { refuse(value => { value.sealed = oldPayload + "." + createHmac("sha256", secret).update(domain).update(oldPayload).digest("hex") }) } for (const field of ["actorId", "planId", "sourceProof"] as const) refuse(value => { value.binding[field] = "different" }) diff --git a/backend/src/services/vrf-production-admission.ts b/backend/src/services/vrf-production-admission.ts index 2980804b..55190d15 100644 --- a/backend/src/services/vrf-production-admission.ts +++ b/backend/src/services/vrf-production-admission.ts @@ -27,7 +27,7 @@ export interface VrfProductionMember { addresses: Array<{ resource: "ipv4_address" | "ipv6_address"; id: string; parentId: string; configurationHash: string }> } export interface VrfProductionAdmission { - version: 2 + version: 3 ticketId: string issuedAt: number expiresAt: number @@ -46,7 +46,7 @@ export const VRF_PRODUCTION_REQUIRED_CATALOGS = ["bridge", "bridge_port", "bridg const idPattern = /^\*[a-f0-9]+$/i const proofPattern = /^[a-f0-9]{64}$/i const singleton = new Set(["ip_settings", "ipv6_settings"]) -const interfaceResources: Record = { ether: "ethernet", ethernet: "ethernet", bridge: "bridge", vlan: "vlan", wireguard: "wireguard", "gre-tunnel": "gre", "ipip-tunnel": "ipip", "eoip-tunnel": "eoip", vxlan: "vxlan", bond: "bonding", bonding: "bonding", "pppoe-out": "pppoe_client" } +const interfaceResources: Record = { ether: "ethernet", ethernet: "ethernet", bridge: "bridge", vlan: "vlan", wg: "wireguard", wireguard: "wireguard", "gre-tunnel": "gre", "ipip-tunnel": "ipip", "eoip-tunnel": "eoip", vxlan: "vxlan", bond: "bonding", bonding: "bonding", "pppoe-out": "pppoe_client" } const nativeIdentity = (id: string): string => id.slice(1).replace(/^0+/, "").toLowerCase() || "0" function requireAdmission(condition: unknown, reason: string): asserts condition { if (!condition) throw new Error(`VRF production admission refused: ${reason}`) @@ -62,7 +62,7 @@ function canonical(value: unknown, depth = 0): string { // low-entropy configuration values in a persisted ticket. Tickets remain // server-private: their authenticated envelope does not encrypt its payload. type DigestPurpose = "snapshot" | "member" | "parent" | "address" | "ticket" -const hash = (value: unknown, secret: Uint8Array, purpose: DigestPurpose): string => createHmac("sha256", secret).update(`mmapp-vrf-production-${purpose}-digest-v2\0`).update(canonical(value)).digest("hex") +const hash = (value: unknown, secret: Uint8Array, purpose: DigestPurpose): string => createHmac("sha256", secret).update(`mmapp-vrf-production-${purpose}-digest-v3\0`).update(canonical(value)).digest("hex") function validateBinding(binding: VrfProductionBinding): void { requireAdmission(Number.isSafeInteger(binding.serverId) && binding.serverId > 0 && proofPattern.test(binding.sourceProof), "server/source binding") requireAdmission(typeof binding.actorId === "string" && binding.actorId.length > 0 && binding.actorId.length <= 200 && typeof binding.planId === "string" && binding.planId.length > 0 && binding.planId.length <= 200, "human/plan binding") @@ -194,7 +194,7 @@ function buildMembers(catalogs: Record, request: Vrf }) } function key(secret: Uint8Array): void { requireAdmission(secret instanceof Uint8Array && secret.byteLength >= 32, "weak sealing key") } -function signature(payload: string, secret: Uint8Array): string { return createHmac("sha256", secret).update("mmapp-vrf-production-admission-v2\0").update(payload).digest("hex") } +function signature(payload: string, secret: Uint8Array): string { return createHmac("sha256", secret).update("mmapp-vrf-production-admission-v3\0").update(payload).digest("hex") } /** Trusted prepare path only: authenticate the human and validate permission * before calling. Persist the returned ticket ID/digest with the plan in the @@ -205,7 +205,7 @@ export function sealVrfProductionAdmission(input: { snapshot: VrfProductionSnaps requireAdmission(Number.isSafeInteger(input.now) && input.now >= 0 && Number.isSafeInteger(ttl) && ttl > 0 && ttl <= 300000 && Number.isSafeInteger(input.now + ttl), "invalid ticket lifetime") const catalogs = validateSnapshot(input.snapshot, input.binding) const selected = buildMembers(catalogs, input.request, input.secret) - const admission: VrfProductionAdmission = { version: 2, ticketId: randomUUID(), issuedAt: input.now, expiresAt: input.now + ttl, binding: structuredClone(input.binding), request: structuredClone(input.request), snapshotHash: hash(catalogs, input.secret, "snapshot"), members: selected, companionExecution: "requires-native-transition-proof" } + const admission: VrfProductionAdmission = { version: 3, ticketId: randomUUID(), issuedAt: input.now, expiresAt: input.now + ttl, binding: structuredClone(input.binding), request: structuredClone(input.request), snapshotHash: hash(catalogs, input.secret, "snapshot"), members: selected, companionExecution: "requires-native-transition-proof" } const payload = Buffer.from(canonical(admission)).toString("base64url") return { sealed: `${payload}.${signature(payload, input.secret)}`, admission, digest: hash(admission, input.secret, "ticket") } } @@ -218,7 +218,7 @@ export function inspectVrfProductionAdmission(input: { sealed: string; secret: U requireAdmission(extra === undefined && /^[A-Za-z0-9_-]+$/.test(payload ?? "") && proofPattern.test(mac ?? ""), "invalid sealed envelope") requireAdmission(timingSafeEqual(Buffer.from(mac, "hex"), Buffer.from(signature(payload, input.secret), "hex")), "ticket signature changed") const admission = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as VrfProductionAdmission - requireAdmission(admission.version === 2 && admission.companionExecution === "requires-native-transition-proof" && typeof admission.ticketId === "string" && /^[a-f0-9-]{36}$/.test(admission.ticketId), "unknown ticket schema") + requireAdmission(admission.version === 3 && admission.companionExecution === "requires-native-transition-proof" && typeof admission.ticketId === "string" && /^[a-f0-9-]{36}$/.test(admission.ticketId), "unknown ticket schema") requireAdmission(Number.isSafeInteger(input.now) && Number.isSafeInteger(admission.issuedAt) && Number.isSafeInteger(admission.expiresAt) && input.now >= admission.issuedAt && input.now < admission.expiresAt && admission.expiresAt - admission.issuedAt <= 300000, "expired/future ticket") requireAdmission(canonical(input.binding) === canonical(admission.binding) && canonical(input.request) === canonical(admission.request), "human/plan/source/request changed") const catalogs = validateSnapshot(input.snapshot, input.binding) diff --git a/backend/src/services/vrf-production-execution.ts b/backend/src/services/vrf-production-execution.ts index 91b58b5e..b7c8f580 100644 --- a/backend/src/services/vrf-production-execution.ts +++ b/backend/src/services/vrf-production-execution.ts @@ -9,6 +9,7 @@ import { assertVrfActionContext, vrfNativeActionFor, vrfActionContractsCurrent, import { findMcpOperation } from "../modules/mcp/registry.js" import { decryptSecretExisting, encryptSecret, fingerprint, McpError, withVrfProductionSealingKey } from "../modules/mcp/security.js" import { configurationRows, resourceById, routerClient, rosSnapshotSourceProof, type RosSnapshot, type RosStep } from "../modules/mcp/resources.js" +import { notifyVrfDiagnostic, vrfExecutionDiagnostic, type VrfExecutionDiagnostic, type VrfExecutionStage, type VrfRawReadObservation } from "./vrf-execution-diagnostic.js" export interface VrfProductionExecutionInput { operationId: string @@ -28,6 +29,8 @@ export interface VrfProductionExecutionInput { /** The existing native policy invokes this AFTER configuration.intent and * before actual HTTP. The runtime owns the closure; never a JSON field. */ installBeforeDispatch(guard: () => Promise): void + /** Private in-process observation only; never authority or public evidence. */ + onDiagnostic?(value: VrfExecutionDiagnostic): unknown } export interface VrfProductionExecutionReceipt { kind: "vrf-production-execution-v1" @@ -121,7 +124,7 @@ export async function executeVrfProductionStep(original: VrfProductionExecutionI const actionId = vrfNativeActionFor(input.operationName, request) const context: VrfTicketContext = { binding, connectionId: input.connectionId, actionId, actionVersion: input.operationName === "routing.vrf.update" ? Number(findMcpOperation(actionId)?.version) : input.operationVersion, - ...(input.operationName === "routing.vrf.update" ? { authorityAction: { id: "routing.vrf.update" as const, version: input.operationVersion as 3 } } : {}) } + ...(input.operationName === "routing.vrf.update" ? { authorityAction: { id: "routing.vrf.update" as const, version: input.operationVersion as 4 } } : {}) } assertVrfActionContext(context, request) const assertSource = async () => { await input.authorize() @@ -130,61 +133,106 @@ export async function executeVrfProductionStep(original: VrfProductionExecutionI await input.authorize() } let acknowledged = false + let executorAckObserved = false, diagnosticReported = false + let stage: VrfExecutionStage = "initial-authorization", read: VrfRawReadObservation | undefined + const mark = (next: VrfExecutionStage) => { stage = next; read = undefined } + const observeRead = (value: VrfRawReadObservation) => { read = value } + const report = (error: unknown) => { + if (diagnosticReported) return + diagnosticReported = true + try { + const diagnostic = vrfExecutionDiagnostic(input.operationId, request.action, stage, error, executorAckObserved, read) + if (diagnostic) notifyVrfDiagnostic(input.onDiagnostic, diagnostic) + } catch { /* Original exception and acknowledgement handling stay authoritative. */ } + } const authorize = async () => { await assertVerifiedConfigurationAccess(); await input.authorize(); if (!vrfActionContractsCurrent(context)) throw new McpError("PLAN_EXPIRED", "Native/public VRF contract изменился; повтор запрещён", 409); await assertIntent(input, acknowledged); await assertSource() } - await authorize() - try { return await withVrfProductionSealingKey(async secret => { + try { + await authorize() + mark("sealing-key") + return await withVrfProductionSealingKey(async secret => { + mark("history-load") const previous = await loadVerifiedVrfProductionHistory({ sql: pool, ownerId: input.ownerId, serverId: binding.serverId, sourceProof: binding.sourceProof, nativeId: request.id, secret }) - const before = await readVrfProductionSnapshot({ serverId: binding.serverId, expectedSourceProof: binding.sourceProof, request, assertAuthority: authorize }) + mark("raw-before") + const before = await readVrfProductionSnapshot({ serverId: binding.serverId, expectedSourceProof: binding.sourceProof, request, assertAuthority: authorize, observeRead }) + mark("original-dependencies") for (const original of input.originalSnapshots) { if (before.catalogs[original.resource] === undefined) continue insist(original.serverId === binding.serverId && original.sourceProof === binding.sourceProof && fingerprint(configurationRows(resourceById(original.resource), before.catalogs[original.resource])) === original.fingerprint, "Original VRF dependency projection изменилась; новый raw ticket не разрешает adoption") } + mark("history-provenance") if (previous) assertVrfProductionHistoryProvenance(previous, before) + mark("supported-shape") supported(before, request) + mark("ticket-lookup") const tickets = await pool.query<{ ticket_id: string }>("SELECT ticket_id FROM vrf_production_tickets WHERE plan_id=$1 AND connection_id=$2", [input.planId, input.connectionId]) insist(tickets.rows.length === 1, "Нет единственного authentic VRF preparation ticket") + mark("ticket-claim") const claimed = await withVrfClaimQuarantine({ operationId: input.operationId, planId: input.planId, connectionId: input.connectionId, ownerId: input.ownerId, journal: pool, claim: () => claimStoredVrfProductionTicket({ context, request, secret, execution: { operationId: input.operationId, stepIndex: input.stepIndex }, sql: pool, ticketId: tickets.rows[0].ticket_id, snapshot: before, assertAuthority: authorize, assertCurrentSource: assertSource }) }) insist(typeof claimed.privateSealed === "string", "Нет authentic private VRF admission") const sealed = claimed.privateSealed + mark("checkpoint-before") const beforeJournal = await pool.query("UPDATE mcp_operation_checkpoints SET before_sealed=$3 WHERE operation_id=$1 AND step_index=$2 RETURNING operation_id", [input.operationId, input.stepIndex, await encryptSecret(JSON.stringify({ kind: "vrf-production-before-v1", before }))]) insist(beforeJournal.rows.length === 1, "Исходный private VRF checkpoint не подтверждён; dispatch запрещён") - const preDispatch = async () => { - await authorize(); await input.assertDependencies() - const current = await readVrfProductionSnapshot({ serverId: binding.serverId, expectedSourceProof: binding.sourceProof, request, requiredCatalogs: Object.keys(before.catalogs), assertAuthority: authorize }) - inspectVrfProductionAdmission({ snapshot: current, binding, request, sealed, secret, now: Date.now() }) + const preDispatch = async (installed = false) => { + const prefix = installed ? "installed" : "explicit" + mark(`${prefix}-predispatch-authorization`) await authorize() + mark(`${prefix}-predispatch-dependencies`) + await input.assertDependencies() + mark(`${prefix}-predispatch-read`) + const current = await readVrfProductionSnapshot({ serverId: binding.serverId, expectedSourceProof: binding.sourceProof, request, requiredCatalogs: Object.keys(before.catalogs), assertAuthority: authorize, observeRead }) + mark(`${prefix}-predispatch-admission`) + inspectVrfProductionAdmission({ snapshot: current, binding, request, sealed, secret, now: Date.now() }) + mark(`${prefix}-predispatch-final-authorization`) + await authorize() + if (installed) mark("client-call") } - input.installBeforeDispatch(preDispatch) + mark("install-predispatch") + input.installBeforeDispatch(() => preDispatch(true)) await preDispatch() + mark("dispatch-source") const { client, server } = await routerClient(binding.serverId) insist(await rosSnapshotSourceProof(server) === binding.sourceProof, "VRF dispatch source изменился") const path = "/ip/vrf" + (request.id ? "/" + encodeURIComponent(request.id) : "") let deleteAck: unknown + mark("client-call") const ack = request.action === "create" ? await client.put(path, { ...request.fields, ...(request.placeBeforeId ? { "place-before": request.placeBeforeId } : {}) }) : request.action === "delete" ? (await client.delete(path, 10000, undefined, body => { acknowledged = true + executorAckObserved = true + mark("ack-identity") try { deleteAck = body.trim() ? JSON.parse(body) : undefined } catch { throw new McpError("VERIFICATION_FAILED", "Native VRF DELETE ACK имеет неизвестный body; повтор запрещён", 409) } }), deleteAck) : await client.patch(path, request.fields) + executorAckObserved = true + mark("ack-identity") const acknowledgedId = request.action === "create" && ack && typeof ack === "object" ? (ack as Record)[".id"] : request.id insist(typeof acknowledgedId === "string", "Нет точного native ACK ID; повтор запрещён") // Receipt is already observed even if the following durable journal fails. acknowledged = true if (request.action !== "create" && ack && typeof ack === "object" && Object.hasOwn(ack, ".id") && (ack as Record)[".id"] !== request.id) throw new McpError("VERIFICATION_FAILED", "Native VRF ACK содержит противоречащий target ID; повтор запрещён", 409) + mark("ack-journal") try { const ackJournal = await pool.query("UPDATE mcp_operation_steps SET state='applied',result=$3::jsonb,finished_at=clock_timestamp() WHERE operation_id=$1 AND step_index=$2 AND state='intent' RETURNING operation_id", [input.operationId, input.stepIndex, JSON.stringify({ kind: "vrf-production-ack-v1", nativeId: acknowledgedId, action: request.action })]) insist(ackJournal.rows.length === 1, "VRF ACK journal не подтверждён") - } catch { throw new McpError("VRF_ACK_JOURNAL_UNCONFIRMED", "Native VRF ACK получен, durable journal не подтверждён; сверка без повтора", 409) } + } catch (error) { report(error); throw new McpError("VRF_ACK_JOURNAL_UNCONFIRMED", "Native VRF ACK получен, durable journal не подтверждён; сверка без повтора", 409) } + mark("post-ack-authorization") await authorize() - const after = await readVrfProductionSnapshot({ serverId: binding.serverId, expectedSourceProof: binding.sourceProof, request: vrfProductionReadSelection(input.step, before.catalogs.vrf.find(row => row[".id"] === request.id)?.interfaces), requiredCatalogs: Object.keys(before.catalogs), assertAuthority: authorize }) + mark("raw-after") + const after = await readVrfProductionSnapshot({ serverId: binding.serverId, expectedSourceProof: binding.sourceProof, request: vrfProductionReadSelection(input.step, before.catalogs.vrf.find(row => row[".id"] === request.id)?.interfaces), requiredCatalogs: Object.keys(before.catalogs), assertAuthority: authorize, observeRead }) + mark("transition-classification") const classified = classifyVrfProductionTransition({ before, after, binding, request, sealed, secret, now: Date.now(), acknowledgedId, previous }) - await input.assertDependencies(); await authorize() + mark("post-ack-dependencies") + await input.assertDependencies() + mark("post-ack-final-authorization") + await authorize() + mark("checkpoint-after") try { const checkpoint = sealVrfProductionTransitionCheckpoint(classified.checkpoint, { serverId: binding.serverId, sourceProof: binding.sourceProof, actorId: binding.actorId, nativeId: acknowledgedId }, secret) const persisted = await pool.query("UPDATE mcp_operation_checkpoints SET after_sealed=$3 WHERE operation_id=$1 AND step_index=$2 RETURNING operation_id", [input.operationId, input.stepIndex, await encryptSecret(JSON.stringify({ kind: "vrf-production-private-checkpoint-v1", sealed: checkpoint }))]) insist(persisted.rows.length === 1, "Verified private VRF checkpoint не подтверждён") - } catch { throw new McpError("VRF_VERIFIED_CHECKPOINT_UNCONFIRMED", "Native VRF ACK получен, private checkpoint не подтверждён; сверка без повтора", 409) } + } catch (error) { report(error); throw new McpError("VRF_VERIFIED_CHECKPOINT_UNCONFIRMED", "Native VRF ACK получен, private checkpoint не подтверждён; сверка без повтора", 409) } const receipt: VrfProductionExecutionReceipt = { kind: "vrf-production-execution-v1", action: request.action, nativeId: acknowledgedId, configurationVerified: true, forwardingVerified: false } return receipt - }) } catch (error) { if (acknowledged) throw new VrfProductionAcknowledgedFailure(error); throw error } + }) } catch (error) { report(error); if (acknowledged) throw new VrfProductionAcknowledgedFailure(error); throw error } } diff --git a/backend/src/services/vrf-production-gre-link-local.test.ts b/backend/src/services/vrf-production-gre-link-local.test.ts new file mode 100644 index 00000000..f6ed1161 --- /dev/null +++ b/backend/src/services/vrf-production-gre-link-local.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict" +import { createHmac } from "node:crypto" +import { readFile } from "node:fs/promises" +import { sealVrfProductionAdmission, VRF_PRODUCTION_REQUIRED_CATALOGS, type VrfProductionRequest, type VrfProductionSnapshot, type VrfProductionRow as Row } from "./vrf-production-admission.js" +import { vrfProductionDependencyObjects, classifyVrfProductionTransition, sealVrfProductionTransitionCheckpoint, openVrfProductionTransitionCheckpoint, assertVrfProductionHistoryProvenance, type VrfProductionTransitionCheckpoint } from "./vrf-production-transition.js" + +const secret = Buffer.alloc(32, 7), now = 1000, sourceProof = "a".repeat(64) +const binding = { serverId: 7, sourceProof, actorId: "human", planId: "gre-local-proof" } +let cases = 0 +const report = JSON.parse(await readFile(new URL("../../../docs/mcp-chr-vrf-companion-native-v5.json", import.meta.url), "utf8")) +// Lifecycle shapes come from the existing native bridge fixture. Converting +// the concrete parents and auto-LL below is a pure synthetic GRE lifecycle +// model, not evidence that those GRE lifecycle reads ran on native routers. +function snapshot(phase: string, gre = true, macBackedGre = false): VrfProductionSnapshot { + const raw = report.phases.find((item: { phase: string }) => item.phase === phase) + const catalogs: Record = Object.fromEntries(VRF_PRODUCTION_REQUIRED_CATALOGS.map(key => [key, structuredClone(raw.owned[key].raw)])) + catalogs.vrf.push({ ".id": "*0", name: "main", interfaces: "all", disabled: "false", builtin: "true" }) + catalogs.routing_table.unshift({ ".id": "*0", name: "main", dynamic: "true", fib: "true" }) + catalogs.ip_settings = [{}]; catalogs.ipv6_settings = [{}] + if (!gre) return { projection: "raw-native-v1", serverId: 7, sourceProof, catalogs } + catalogs.gre = catalogs.bridge.map((row, index) => ({ ".id": row[".id"], name: row.name, "local-address": `198.18.245.${index ? 13 : 9}`, "remote-address": `198.18.245.${index ? 14 : 10}` })) + const greNames = new Set(catalogs.gre.map(row => row.name)) + for (const row of catalogs.interface_inventory) if (greNames.has(row.name)) { row.type = "gre-tunnel"; if (!macBackedGre) delete row["mac-address"] } + if (!macBackedGre) for (const row of catalogs.ipv6_address) if (row.dynamic === "true" && greNames.has(row.interface)) { + row.address = row.interface === catalogs.gre[0].name ? "fe80::2:c612:f50a/64" : "fe80::2:c612:f50e/64" + // Synthetic correction from observed GRE context; not native lifecycle proof. + row.vrf = catalogs.interface_inventory.find(item => item.name === row.interface)!.vrf + } + if (!macBackedGre) for (const row of catalogs.interface_inventory.filter(item => greNames.has(item.name))) { + // Synthetic ordered-context cycle model, not native forward-cycle proof. + const contextPhases = ["addresses-before-vrf", "vrf-create", "vrf-rename", "vrf-disable", "vrf-reenable", "vrf-membership-transfer-a-to-b", "vrf-delete-addresses-remain"] + let previous = "main", cycles = 0 + for (const step of contextPhases.slice(0, contextPhases.indexOf(phase) + 1)) { + const vector = report.phases.find((item: { phase: string }) => item.phase === step) + const current = String(vector.owned.interface_inventory.raw.find((item: Row) => item.name === row.name).vrf) + if (current !== previous) cycles++ + previous = current + } + const clock = (seconds: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString().slice(0, 19).replace("T", " ") + row["link-downs"] = String(cycles); row["last-link-up-time"] = clock(cycles * 2) + if (cycles) row["last-link-down-time"] = clock(cycles * 2 - 1) + else delete row["last-link-down-time"] + } + catalogs.bridge = [] + catalogs.wireguard = [{ ".id": "*ff11", name: "gre-source-wg" }]; catalogs.wireguard_peer = [] + catalogs.interface_inventory.push({ ".id": "*ff11", name: "gre-source-wg", type: "wg", vrf: "main" }) + for (const [index, host] of [9, 13].entries()) catalogs.ipv4_address.push({ ".id": `*ff2${index}`, interface: "gre-source-wg", address: `198.18.245.${host}/30`, dynamic: "false", disabled: "false", vrf: "main" }) + return { projection: "raw-native-v1", serverId: 7, sourceProof, catalogs } +} +function input(before: VrfProductionSnapshot, after: VrfProductionSnapshot, request: VrfProductionRequest, previous?: VrfProductionTransitionCheckpoint, acknowledgedId?: string) { + const issued = sealVrfProductionAdmission({ snapshot: before, binding, request, secret, now }) + return { before, after, request, previous, acknowledgedId, binding, secret, now, sealed: issued.sealed } +} +const stages = ["vrf-create", "vrf-rename", "vrf-disable", "vrf-reenable", "vrf-membership-transfer-a-to-b", "vrf-delete-addresses-remain"] +const inputs: ReturnType[] = [] +let before = snapshot("addresses-before-vrf"), previous: VrfProductionTransitionCheckpoint | undefined +for (const phase of stages) { + const command = report.journal.find((item: { phase: string }) => item.phase === phase), after = snapshot(phase) + const request: VrfProductionRequest = { action: command.method === "PUT" ? "create" : command.method === "DELETE" ? "delete" : "patch", ...(command.id ? { id: command.id } : {}), fields: command.fields, ...(command.method === "PUT" ? { placeBeforeId: "*0" } : {}) } + const current = input(before, after, request, previous, command.assignedId) + const result = classifyVrfProductionTransition(current) + const scope = { serverId: 7, sourceProof, actorId: binding.actorId, nativeId: command.assignedId ?? command.id } + const sealed = sealVrfProductionTransitionCheckpoint(result.checkpoint, scope, secret) + previous = openVrfProductionTransitionCheckpoint(sealed, scope, secret) + assertVrfProductionHistoryProvenance(previous, after) + inputs.push(current); before = after; cases++ +} +// These four LL/endpoints/no-MAC inventory observations are from the exact V29 +// native capture. Unrelated raw17 and concrete parent defaults are synthetic. +for (const [id, name, local, remote, ll] of [ + ["*2A", "mcp-route-8b47e64eec5a-gre-a", "198.18.245.9", "198.18.245.10", "fe80::2:c612:f50a/64"], + ["*2B", "mcp-route-8b47e64eec5a-gre-b", "198.18.245.13", "198.18.245.14", "fe80::2:c612:f50e/64"], + ["*1E", "mcp-route-8b47e64eec5a-gre-a", "198.18.245.10", "198.18.245.9", "fe80::2:c612:f509/64"], + ["*1F", "mcp-route-8b47e64eec5a-gre-b", "198.18.245.14", "198.18.245.13", "fe80::2:c612:f50d/64"], +]) { + const s = snapshot("addresses-before-vrf") + s.catalogs.gre = [{ ".id": id, name, "local-address": local, "remote-address": remote }] + s.catalogs.interface_inventory = [{ ".id": id, name, type: "gre-tunnel", vrf: "main", disabled: "false", running: "true" }, { ".id": "*ff11", name: "gre-source-wg", type: "wg", vrf: "main" }] + s.catalogs.ipv4_address = [{ ".id": "*ff20", address: `${local}/30`, interface: "gre-source-wg", disabled: "false", dynamic: "false", vrf: "main" }] + s.catalogs.ipv6_address = [{ ".id": "*33", address: ll, interface: name, "actual-interface": name, dynamic: "true", disabled: "false", invalid: "false", vrf: "main", "auto-link-local": "true", "link-local": "true" }] + s.catalogs.ipv6_route = [{ ".id": "*ff30", "dst-address": "fe80::/64", dynamic: "true", connect: "true", "routing-table": "main", gateway: name, "immediate-gw": name }] + const request: VrfProductionRequest = { action: "create", placeBeforeId: "*0", fields: { name: "capture-vrf", interfaces: name } } + const { admission } = sealVrfProductionAdmission({ snapshot: s, binding, request, secret, now }) + assert.ok(vrfProductionDependencyObjects(s, admission).some(row => row.kind === "ipv6_route")); cases++ +} +const base = inputs[0] +const selected = String(base.request.fields.interfaces) +const ll = (s: VrfProductionSnapshot) => s.catalogs.ipv6_address.find(row => row.interface === selected && row.dynamic === "true")! +const inventory = (s: VrfProductionSnapshot) => s.catalogs.interface_inventory.find(row => row.name === selected)! +for (const mutate of [ + (s: VrfProductionSnapshot) => { s.catalogs.gre[0]["remote-address"] = "198.18.245.99" }, + (s: VrfProductionSnapshot) => { s.catalogs.gre[0]["local-address"] = "198.18.245.99" }, + (s: VrfProductionSnapshot) => { s.catalogs.gre = [] }, + (s: VrfProductionSnapshot) => { s.catalogs.gre.push({ ...s.catalogs.gre[0], ".id": "*ff88" }) }, + (s: VrfProductionSnapshot) => { inventory(s).type = "ether" }, + (s: VrfProductionSnapshot) => { inventory(s)["mac-address"] = "02:00:00:00:00:01" }, + ...["auto-link-local", "link-local", "disabled"].map(field => (s: VrfProductionSnapshot) => { ll(s)[field] = field === "disabled" ? "true" : "false" }), + (s: VrfProductionSnapshot) => { ll(s).address = "fe80::2:c612:f50a/128" }, + (s: VrfProductionSnapshot) => { ll(s).address = "fe80::cafe/64" }, + (s: VrfProductionSnapshot) => { ll(s)["actual-interface"] = "foreign" }, + (s: VrfProductionSnapshot) => { ll(s).invalid = "true" }, + (s: VrfProductionSnapshot) => { s.catalogs.ipv6_route[0].gateway = "foreign" }, +]) { + const changed = structuredClone(base.before); mutate(changed) + assert.throws(() => { const issued = sealVrfProductionAdmission({ snapshot: changed, binding, request: base.request, secret, now }); vrfProductionDependencyObjects(changed, issued.admission) }, /VRF production/); cases++ +} +// Parent/config/endpoint changes cannot hide behind a newly approved before, +// nor behind serialized checkpoint histories while an acquired LL is absent. +const absentInput = inputs[5], checkpoint = absentInput.previous! +for (const mutate of [ + (s: VrfProductionSnapshot) => { s.catalogs.gre[1][".id"] = "*ff99" }, + (s: VrfProductionSnapshot) => { s.catalogs.gre[1]["remote-address"] = "198.18.245.15" }, + (s: VrfProductionSnapshot) => { s.catalogs.gre[1]["local-address"] = "198.18.245.9" }, + (s: VrfProductionSnapshot) => { s.catalogs.wireguard[0][".id"] = "*ff99" }, + (s: VrfProductionSnapshot) => { s.catalogs.interface_inventory.find(row => row.type === "wg")![".id"] = "*ff98" }, + (s: VrfProductionSnapshot) => { s.catalogs.interface_inventory.find(row => row.type === "wg")!.type = "ether" }, + (s: VrfProductionSnapshot) => { s.catalogs.ipv4_address.find(row => row.interface === "gre-source-wg")![".id"] = "*ff98" }, + (s: VrfProductionSnapshot) => { const row = s.catalogs.ipv4_address.find(row => row.interface === "gre-source-wg")!; row.address = String(row.address).replace("/30", "/29") }, + (s: VrfProductionSnapshot) => { s.catalogs.interface_inventory.find(row => row.name === s.catalogs.gre[1].name)!["mac-address"] = "02:00:00:00:00:01" }, +]) { const changed = structuredClone(absentInput.before); mutate(changed); assert.throws(() => assertVrfProductionHistoryProvenance(checkpoint, changed)); cases++ } +const foreign = structuredClone(base.after); foreign.catalogs.ipv6_route.push({ ".id": "*ffaa", opaque: "foreign" }) +assert.throws(() => classifyVrfProductionTransition({ ...base, after: foreign })); cases++ +const staticAdoption = structuredClone(base.after); ll(staticAdoption).dynamic = "false" +assert.throws(() => classifyVrfProductionTransition({ ...base, after: staticAdoption })); cases++ +for (const mutate of [ + (s: VrfProductionSnapshot) => { s.catalogs.gre[0][".id"] = "*ff99" }, + (s: VrfProductionSnapshot) => { s.catalogs.gre[0]["remote-address"] = "198.18.245.11"; ll(s).address = "fe80::2:c612:f50b/64" }, + (s: VrfProductionSnapshot) => { s.catalogs.gre[0]["local-address"] = "198.18.245.13" }, + (s: VrfProductionSnapshot) => { inventory(s).type = "bridge" }, + (s: VrfProductionSnapshot) => { inventory(s)["mac-address"] = "02:00:00:00:00:01" }, +]) { const changed = structuredClone(base.after); mutate(changed); assert.throws(() => classifyVrfProductionTransition({ ...base, after: changed })); cases++ } +assert.throws(() => classifyVrfProductionTransition({ ...base, now: now + 300000 })); cases++ +assert.throws(() => classifyVrfProductionTransition({ ...base, binding: { ...binding, actorId: "foreign" } })); cases++ +assert.throws(() => classifyVrfProductionTransition({ ...base, after: { ...base.after, sourceProof: "b".repeat(64) } })); cases++ +// Authenticated legacy v1 EUI64 histories remain supported. A deliberately +// incomplete v1 GRE absence checkpoint must fail closed after authentication. +const scope = { serverId: 7, sourceProof, actorId: binding.actorId, nativeId: absentInput.request.id! } +const encoded = sealVrfProductionTransitionCheckpoint(checkpoint, scope, secret) +const legacy = JSON.parse(Buffer.from(encoded.split(".")[0], "base64url").toString()) +legacy.version = 1; delete legacy.history.provenance.greMembers +for (const row of legacy.history.provenance.missingMembers) delete row.greParent +const payload = Buffer.from(JSON.stringify(legacy)).toString("base64url") +const signature = createHmac("sha256", secret).update("mmapp-vrf-production-transition-history-v1\0").update(payload).digest("hex") +assert.throws(() => openVrfProductionTransitionCheckpoint(`${payload}.${signature}`, scope, secret), /incomplete legacy GRE provenance/); cases++ +function asLegacy(sealed: string): string { + const value = JSON.parse(Buffer.from(sealed.split(".")[0], "base64url").toString()) + value.version = 1; delete value.history.provenance.greMembers + for (const row of value.history.provenance.missingMembers) delete row.greParent + const payload = Buffer.from(JSON.stringify(value)).toString("base64url") + return payload + "." + createHmac("sha256", secret).update("mmapp-vrf-production-transition-history-v1\0").update(payload).digest("hex") +} +const createdScope = { ...scope, nativeId: base.acknowledgedId! } +const oldGre = openVrfProductionTransitionCheckpoint(asLegacy(sealVrfProductionTransitionCheckpoint(inputs[1].previous!, createdScope, secret)), createdScope, secret) +assert.throws(() => assertVrfProductionHistoryProvenance(oldGre, base.after), /incomplete legacy GRE provenance/); cases++ +const oldGreResealed = sealVrfProductionTransitionCheckpoint(oldGre, createdScope, secret) +assert.equal(JSON.parse(Buffer.from(oldGreResealed.split(".")[0], "base64url").toString()).version, 1); cases++ +const nativeBefore = snapshot("addresses-before-vrf", false), nativeAfter = snapshot("vrf-create", false) +const nativeResult = classifyVrfProductionTransition(input(nativeBefore, nativeAfter, base.request, undefined, base.acknowledgedId)) +const oldEui64 = openVrfProductionTransitionCheckpoint(asLegacy(sealVrfProductionTransitionCheckpoint(nativeResult.checkpoint, createdScope, secret)), createdScope, secret) +assertVrfProductionHistoryProvenance(oldEui64, nativeAfter) +classifyVrfProductionTransition(input(nativeAfter, snapshot("vrf-rename", false), inputs[1].request, oldEui64)); cases++ +let legacyBefore = nativeAfter, legacyPrevious = oldEui64 +for (let index = 1; index < stages.length; index++) { + const after = snapshot(stages[index], false) + const result = classifyVrfProductionTransition(input(legacyBefore, after, inputs[index].request, legacyPrevious)) + legacyPrevious = openVrfProductionTransitionCheckpoint(asLegacy(sealVrfProductionTransitionCheckpoint(result.checkpoint, createdScope, secret)), createdScope, secret) + assertVrfProductionHistoryProvenance(legacyPrevious, after) + legacyBefore = after; cases++ +} +// GREs with a real MAC retain the original strict EUI64 dispatch and legacy +// member witness, including old v1 absence/revival history compatibility. +let macBefore = snapshot("addresses-before-vrf", true, true), macPrevious: VrfProductionTransitionCheckpoint | undefined +const macAdmission = sealVrfProductionAdmission({ snapshot: macBefore, binding, request: base.request, secret, now }).admission +assert.ok(vrfProductionDependencyObjects(macBefore, macAdmission).some(row => row.kind === "ipv6_route")); cases++ +for (let index = 0; index < stages.length; index++) { + const after = snapshot(stages[index], true, true) + const result = classifyVrfProductionTransition(input(macBefore, after, inputs[index].request, macPrevious, inputs[index].acknowledgedId)) + macPrevious = openVrfProductionTransitionCheckpoint(asLegacy(sealVrfProductionTransitionCheckpoint(result.checkpoint, createdScope, secret)), createdScope, secret) + assertVrfProductionHistoryProvenance(macPrevious, after) + macBefore = after; cases++ +} +// Even a valid replacement MAC/EUI64 pair cannot replace a historical no-MAC +// GRE witness under a fresh approval or while its acquired LL remains absent. +const macReplacement = structuredClone(absentInput.before) +const acquired = macReplacement.catalogs.interface_inventory.find(row => row.name === macReplacement.catalogs.gre[1].name)! +acquired["mac-address"] = "02:00:00:00:00:01" +assert.throws(() => assertVrfProductionHistoryProvenance(checkpoint, macReplacement)); cases++ +const presentReplacement = structuredClone(base.after) +inventory(presentReplacement)["mac-address"] = "02:00:00:00:00:01" +ll(presentReplacement).address = "fe80::ff:fe00:1/64" +assert.throws(() => assertVrfProductionHistoryProvenance(inputs[1].previous!, presentReplacement)); cases++ +// Optional secrets and noncausal source annotations never enter the durable +// witness. Their complete values remain fenced within a current transition. +const secretBefore = structuredClone(base.before), secretAfter = structuredClone(base.after) +for (const s of [secretBefore, secretAfter]) { + s.catalogs.gre[0]["ipsec-secret"] = "GRE-secret-must-never-be-serialized" + s.catalogs.ipv4_address.find(row => row.interface === "gre-source-wg")!.comment = "source-annotation-must-not-be-serialized" +} +const secretResult = classifyVrfProductionTransition(input(secretBefore, secretAfter, base.request, undefined, base.acknowledgedId)) +const secretSealed = sealVrfProductionTransitionCheckpoint(secretResult.checkpoint, createdScope, secret) +const secretPayload = Buffer.from(secretSealed.split(".")[0], "base64url").toString() +assert.equal(secretPayload.includes("ipsec-secret"), false) +assert.equal(secretPayload.includes("GRE-secret-must-never-be-serialized"), false) +assert.equal(secretPayload.includes("source-annotation-must-not-be-serialized"), false) +const secretOpened = openVrfProductionTransitionCheckpoint(secretSealed, createdScope, secret) +assertVrfProductionHistoryProvenance(secretOpened, secretAfter); cases++ +const currentSecretDrift = structuredClone(secretAfter) +currentSecretDrift.catalogs.gre[0]["ipsec-secret"] = "changed-during-current-transition" +assert.throws(() => classifyVrfProductionTransition(input(secretBefore, currentSecretDrift, base.request, undefined, base.acknowledgedId))); cases++ +console.log("GRE captured auto-LL, synthetic lifecycle, durable endpoints, negative conservation cases:", cases) diff --git a/backend/src/services/vrf-production-projection.ts b/backend/src/services/vrf-production-projection.ts index 9cf4100d..692d9d68 100644 --- a/backend/src/services/vrf-production-projection.ts +++ b/backend/src/services/vrf-production-projection.ts @@ -1,6 +1,7 @@ /** Reviewed top-level telemetry spellings are scoped to native REST catalogs. * Unknown catalogs/fields, nested values and familiar names on other paths - * remain configuration. This projection is the v2 private admission contract. */ + * remain configuration. New admissions use v3; the immutable v2 rules below + * remain available only to compare authenticated legacy snapshot proofs. */ const interfaceTelemetry = ["running", "actual-mtu", "current-mac-address", "rx-byte", "tx-byte", "rx-packet", "tx-packet", "rx-drop", "tx-drop", "rx-error", "tx-error", "tx-queue-drop", "fp-rx-byte", "fp-tx-byte", "fp-rx-packet", "fp-tx-packet"] const resourceTelemetry: Readonly>> = { bridge: new Set(interfaceTelemetry), ethernet: new Set(interfaceTelemetry), interface_inventory: new Set(interfaceTelemetry), @@ -10,8 +11,14 @@ const resourceTelemetry: Readonly>> = { ip_settings: new Set(["ipv4-fast-path-active", "ipv4-fasttrack-active", "ipv4-fast-path-packets", "ipv4-fast-path-bytes", "ipv4-fasttrack-packets", "ipv4-fasttrack-bytes"]), ipv6_settings: new Set(["ipv6-fast-path-active", "ipv6-fast-path-packets", "ipv6-fast-path-bytes"]), } -export const isVrfProductionTelemetry = (resource: string, field: string): boolean => resourceTelemetry[resource]?.has(field) === true -export const isVrfProductionTelemetryValue = (resource: string, field: string, value: unknown): boolean => isVrfProductionTelemetry(resource, field) && (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) +const wireguardPeerTelemetry = new Set(["rx", "tx", "last-handshake"]) +const scalar = (value: unknown): boolean => typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) +export const isVrfProductionTelemetry = (resource: string, field: string): boolean => resourceTelemetry[resource]?.has(field) === true || resource === "wireguard_peer" && wireguardPeerTelemetry.has(field) +export const isVrfProductionTelemetryValue = (resource: string, field: string, value: unknown): boolean => isVrfProductionTelemetry(resource, field) && scalar(value) export function vrfProductionConfiguration(resource: string, row: Record, omit: readonly string[] = []): Record { return Object.fromEntries(Object.entries(row).filter(([field, value]) => !isVrfProductionTelemetryValue(resource, field, value) && !omit.includes(field))) } +/** Exact pre-v3 projection. Never use to prepare/claim a new admission. */ +export function vrfProductionConfigurationV2(resource: string, row: Record, omit: readonly string[] = []): Record { + return Object.fromEntries(Object.entries(row).filter(([field, value]) => !(resourceTelemetry[resource]?.has(field) === true && scalar(value)) && !omit.includes(field))) +} diff --git a/backend/src/services/vrf-production-read.test.ts b/backend/src/services/vrf-production-read.test.ts index 7f412b65..331819f5 100644 --- a/backend/src/services/vrf-production-read.test.ts +++ b/backend/src/services/vrf-production-read.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readVrfProductionSnapshot, VRF_PRODUCTION_RAW_PATHS, type VrfProductionReadAdapter, type VrfProductionRawClient } from "./vrf-production-read.js" import { VRF_PRODUCTION_REQUIRED_CATALOGS, sealVrfProductionAdmission, type VrfProductionRequest, type VrfProductionSnapshot } from "./vrf-production-admission.js" +import type { VrfRawReadObservation } from "./vrf-execution-diagnostic.js" const sourceProof = "a".repeat(64) const request: VrfProductionRequest = { action: "patch", id: "*1", fields: { name: "customers" } } @@ -147,3 +148,65 @@ for (let i = 0; i < 130; i++) { deep.catalogs.vrf[0].interfaces = "vlan-0" await assert.rejects(readVrfProductionSnapshot(deep.input), /closure budget/) console.log("VRF production raw reader model tests passed") + +// Optional observation adds no GET, authentication, source resolution or timer. +const observed = model(), observations: VrfRawReadObservation[] = [] +assert.deepEqual(await readVrfProductionSnapshot({ ...observed.input, observeRead: value => observations.push(value) }), snapshot) +assert.deepEqual(observed.calls, happy.calls) +assert.deepEqual(observed.counters(), happy.counters()) +assert.equal(observations.length, observed.calls.length * 5 + 2) +assert.deepEqual(observations.slice(0, 4).map(value => value.boundary), ["authority-before", "native-get", "authority-after", "shape"]) +assert.ok(observations.every(value => value.serverId === 7)) +assert.deepEqual(observations.at(-2), { serverId: 7, boundary: "final-authority" }) +assert.deepEqual(observations.at(-1), { serverId: 7, boundary: "snapshot-validation" }) +for (const rejectAsync of [false, true]) { + const unchanged = model() + await readVrfProductionSnapshot({ ...unchanged.input, observeRead: () => { + if (rejectAsync) return Promise.reject(Error("private observer failure")) + throw Error("private observer failure") + } }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(unchanged.calls, happy.calls) + assert.deepEqual(unchanged.counters(), happy.counters()) +} +const failedGet = model(), readError = Object.assign(Error("PRIVATE_NATIVE_JSON_OR_TRANSPORT"), { code: "ECONNRESET" }), readEvents: VrfRawReadObservation[] = [] +const resolveSource = failedGet.input.adapter.resolve +failedGet.input.adapter.resolve = async serverId => { + const source = await resolveSource(serverId) + return { ...source, client: { async get(): Promise { throw readError } } } +} +await assert.rejects(readVrfProductionSnapshot({ ...failedGet.input, observeRead: value => readEvents.push(value) }), error => error === readError) +assert.deepEqual(readEvents.at(-1), { serverId: 7, catalog: "bridge", boundary: "native-get" }) +const failedShape = model(), shapeEvents: VrfRawReadObservation[] = [] +failedShape.payloads.bridge = { PRIVATE_NATIVE_ROWS: "not a catalog" } +await assert.rejects(readVrfProductionSnapshot({ ...failedShape.input, observeRead: value => shapeEvents.push(value) }), /incomplete\/oversized raw catalog/) +assert.deepEqual(shapeEvents.at(-1), { serverId: 7, catalog: "bridge", boundary: "shape" }) +const failedAuthority = model(), authorityEvents: VrfRawReadObservation[] = [] +failedAuthority.afterGet(() => failedAuthority.revoke()) +await assert.rejects(readVrfProductionSnapshot({ ...failedAuthority.input, observeRead: value => authorityEvents.push(value) })) +assert.deepEqual(authorityEvents.at(-1), { serverId: 7, catalog: "bridge", boundary: "authority-after" }) +assert.equal(JSON.stringify([...readEvents, ...shapeEvents, ...authorityEvents]).includes("PRIVATE"), false) +for (const kind of ["inventory", "closure", "source"] as const) { + const local = model(), localEvents: VrfRawReadObservation[] = [] + if (kind === "inventory") local.catalogs.interface_inventory.push({ ".id": "*ff88", name: "ether1", type: "ether" }) + if (kind === "closure") local.input.request.fields.interfaces = "missing-selected-member" + if (kind === "source") { + local.catalogs.interface_inventory.push({ ".id": "*50", name: "customer-gre", type: "gre-tunnel" }) + local.catalogs.gre = [{ ".id": "*60", name: "customer-gre", "local-address": "0.0.0.0", "remote-address": "198.51.100.1" }] + local.input.request.fields.interfaces = "customer-gre" + } + await assert.rejects(readVrfProductionSnapshot({ ...local.input, observeRead: value => localEvents.push(value) })) + assert.deepEqual(localEvents.at(-1), { serverId: 7, boundary: "snapshot-validation" }, kind) + assert.ok(local.calls.length >= VRF_PRODUCTION_REQUIRED_CATALOGS.length) +} +const budget = model(), budgetEvents: VrfRawReadObservation[] = [] +for (const resource of ["bridge", "bridge_port", "bridge_vlan", "ip_filter", "ipv6_filter"]) budget.catalogs[resource] = [{ ".id": "*ff88", opaque: "x".repeat(7 * 1024 * 1024) }] +await assert.rejects(readVrfProductionSnapshot({ ...budget.input, observeRead: value => budgetEvents.push(value) }), /raw evidence byte budget/) +assert.deepEqual(budgetEvents.at(-1), { serverId: 7, boundary: "snapshot-validation" }) +const finalAuthority = model(), finalEvents: VrfRawReadObservation[] = [] +finalAuthority.afterResolve(() => { + if (finalAuthority.counters().resolveCalls === happy.counters().resolveCalls) finalAuthority.revoke() +}) +await assert.rejects(readVrfProductionSnapshot({ ...finalAuthority.input, observeRead: value => finalEvents.push(value) })) +assert.deepEqual(finalEvents.at(-1), { serverId: 7, boundary: "final-authority" }) +console.log("VRF raw-reader optional diagnostics: same IO/order/timers and original read/shape/authority failures passed") diff --git a/backend/src/services/vrf-production-read.ts b/backend/src/services/vrf-production-read.ts index aa38d0f7..4a33762b 100644 --- a/backend/src/services/vrf-production-read.ts +++ b/backend/src/services/vrf-production-read.ts @@ -1,5 +1,6 @@ import { isIP } from "node:net" import { VRF_PRODUCTION_REQUIRED_CATALOGS, type VrfProductionRequest, type VrfProductionRow, type VrfProductionSnapshot } from "./vrf-production-admission.js" +import { notifyVrfDiagnostic, type VrfRawReadObservation } from "./vrf-execution-diagnostic.js" /** Raw, complete REST catalogs. Never replace with configurationRows(): that * projection drops native companions, unknown fields and dynamic link-local. */ @@ -11,7 +12,7 @@ export const VRF_PRODUCTION_RAW_PATHS: Readonly> = Object vlan: "/interface/vlan", bonding: "/interface/bonding", wireguard: "/interface/wireguard", wireguard_peer: "/interface/wireguard/peers", gre: "/interface/gre", ipip: "/interface/ipip", eoip: "/interface/eoip", vxlan: "/interface/vxlan", vxlan_vtep: "/interface/vxlan/vteps", pppoe_client: "/interface/pppoe-client", }) -const types: Readonly> = Object.freeze({ ether: "ethernet", ethernet: "ethernet", bridge: "bridge", vlan: "vlan", bond: "bonding", bonding: "bonding", wireguard: "wireguard", "gre-tunnel": "gre", "ipip-tunnel": "ipip", "eoip-tunnel": "eoip", vxlan: "vxlan", "pppoe-out": "pppoe_client" }) +const types: Readonly> = Object.freeze({ ether: "ethernet", ethernet: "ethernet", bridge: "bridge", vlan: "vlan", bond: "bonding", bonding: "bonding", wg: "wireguard", wireguard: "wireguard", "gre-tunnel": "gre", "ipip-tunnel": "ipip", "eoip-tunnel": "eoip", vxlan: "vxlan", "pppoe-out": "pppoe_client" }) const idPattern = /^\*[a-f0-9]+$/i const nativeIdentity = (id: string): string => id.slice(1).replace(/^0+/, "").toLowerCase() || "0" const singleton = new Set(["ip_settings", "ipv6_settings"]) @@ -41,6 +42,8 @@ export interface ReadVrfProductionInput { * first GET. Caller JSON must never supply this callback or the adapter. */ assertAuthority(): Promise adapter?: VrfProductionReadAdapter + /** Trusted in-process observer only; no reads, timers or authority are added. */ + observeRead?(value: VrfRawReadObservation): unknown } function insist(value: unknown, reason: string): asserts value { if (!value) throw new Error(`VRF production read refused: ${reason}`) @@ -107,10 +110,17 @@ export async function readVrfProductionSnapshot(input: ReadVrfProductionInput): let totalBytes = 0 const read = async (resource: string): Promise => { insist(Object.hasOwn(VRF_PRODUCTION_RAW_PATHS, resource), "unknown raw resource") + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, catalog: resource, boundary: "authority-before" }) const source = await fence() + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, catalog: resource, boundary: "native-get" }) const payload = await source.client.get(VRF_PRODUCTION_RAW_PATHS[resource], 10000, undefined, MAX_BYTES) + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, catalog: resource, boundary: "authority-after" }) await fence() + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, catalog: resource, boundary: "shape" }) const catalog = rows(resource, payload) + // Catalog shape succeeded. Budgets and subsequent closure validation are + // snapshot work, not a failure of this completed native GET/catalog shape. + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, boundary: "snapshot-validation" }) const size = Buffer.byteLength(JSON.stringify(catalog)) totalBytes += size insist(size < MAX_BYTES && totalBytes < MAX_TOTAL_BYTES, "raw evidence byte budget") @@ -199,6 +209,8 @@ export async function readVrfProductionSnapshot(input: ReadVrfProductionInput): const family = resource === "ipv4_address" ? 4 : 6 insist(extra === undefined && isIP(host) === family && /^\d+$/.test(prefix ?? "") && Number(prefix) <= (family === 4 ? 32 : 128), "invalid native address prefix") } + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, boundary: "final-authority" }) await fence() + notifyVrfDiagnostic(input.observeRead, { serverId: input.serverId, boundary: "snapshot-validation" }) return { projection: "raw-native-v1", serverId: input.serverId, sourceProof: input.expectedSourceProof, catalogs } } diff --git a/backend/src/services/vrf-production-transition.ts b/backend/src/services/vrf-production-transition.ts index 08436de4..9ecfaaeb 100644 --- a/backend/src/services/vrf-production-transition.ts +++ b/backend/src/services/vrf-production-transition.ts @@ -1,13 +1,14 @@ +import { noteVrfTransitionRefusal, withVrfTransitionSection, type VrfTransitionGuard } from "./vrf-transition-refusal.js" import { isIP } from "node:net" import { isDeepStrictEqual } from "node:util" import { createHash, createHmac, timingSafeEqual } from "node:crypto" import { inspectVrfProductionAdmission, VRF_PRODUCTION_REQUIRED_CATALOGS, type VrfProductionAdmission, type VrfProductionBinding, type VrfProductionRequest, type VrfProductionRow, type VrfProductionSnapshot } from "./vrf-production-admission.js" -import { isVrfProductionTelemetryValue, vrfProductionConfiguration } from "./vrf-production-projection.js" +import { isVrfProductionTelemetryValue, vrfProductionConfiguration, vrfProductionConfigurationV2 } from "./vrf-production-projection.js" type Row = VrfProductionRow export interface VrfProductionTransitionCheckpoint { readonly kind: "vrf-production-transition-checkpoint-v1" } -interface SnapshotProof { kind: "configuration-sha256-v1"; serverId: number; sourceProof: string; catalogDigests: Record } -interface CausalProvenance { inactiveCycle?: Row; missingMembers: Row[] } +interface SnapshotProof { kind: "configuration-sha256-v1" | "configuration-sha256-v2"; serverId: number; sourceProof: string; catalogDigests: Record } +interface CausalProvenance { inactiveCycle?: Row; missingMembers: Row[]; greMembers?: Row[] } interface History { snapshot: VrfProductionSnapshot | SnapshotProof; vrfId: string; activeVirtual?: Row; dormantTable?: Row; absentLl: Array<{ address: Row; route: Row; member: string }>; provenance: CausalProvenance } const histories = new WeakMap() export interface VrfProductionCheckpointScope { serverId: number; sourceProof: string; actorId: string; nativeId: string } @@ -24,7 +25,8 @@ export function sealVrfProductionTransitionCheckpoint(checkpoint: VrfProductionT const history = histories.get(checkpoint) must(history && history.vrfId === scope.nativeId && history.snapshot.serverId === scope.serverId && history.snapshot.sourceProof === scope.sourceProof && scope.actorId.length > 0 && secret.byteLength >= 32, "checkpoint scope") const durable = { ...history, snapshot: "projection" in history.snapshot ? snapshotProof(history.snapshot) : history.snapshot } - const text = JSON.stringify({ version: 1, scope, history: durable }) + // Re-sealing legacy history cannot mint the missing GRE witnesses. + const text = JSON.stringify({ version: history.provenance.greMembers === undefined ? 1 : 2, scope, history: durable }) must(Buffer.byteLength(text) <= maxCheckpointBytes, "private checkpoint budget") const payload = Buffer.from(text).toString("base64url") return payload + "." + createHmac("sha256", secret).update(checkpointPurpose).update(payload).digest("hex") @@ -37,9 +39,10 @@ export function openVrfProductionTransitionCheckpoint(sealed: string, scope: Vrf must(timingSafeEqual(expected, Buffer.from(parts[1], "hex")), "private checkpoint signature") const bytes = Buffer.from(parts[0], "base64url"); must(bytes.byteLength <= maxCheckpointBytes, "private checkpoint budget") const value = JSON.parse(bytes.toString("utf8")) as { version: number; scope: VrfProductionCheckpointScope; history: History } - must(value.version === 1 && isDeepStrictEqual(value.scope, scope) && value.history?.vrfId === scope.nativeId, "private checkpoint scope/version") + must([1, 2].includes(value.version) && isDeepStrictEqual(value.scope, scope) && value.history?.vrfId === scope.nativeId, "private checkpoint scope/version") const proof = value.history.snapshot as SnapshotProof - must(proof?.kind === "configuration-sha256-v1" && proof.catalogDigests && VRF_PRODUCTION_REQUIRED_CATALOGS.every(key => Object.hasOwn(proof.catalogDigests, key)) && Object.values(proof.catalogDigests).every(digest => /^[a-f0-9]{64}$/.test(digest)) && proof.serverId === scope.serverId && proof.sourceProof === scope.sourceProof && Array.isArray(value.history.absentLl) && Array.isArray(value.history.provenance?.missingMembers), "private checkpoint source/history") + must(["configuration-sha256-v1", "configuration-sha256-v2"].includes(proof?.kind) && proof.catalogDigests && VRF_PRODUCTION_REQUIRED_CATALOGS.every(key => Object.hasOwn(proof.catalogDigests, key)) && Object.values(proof.catalogDigests).every(digest => /^[a-f0-9]{64}$/.test(digest)) && proof.serverId === scope.serverId && proof.sourceProof === scope.sourceProof && Array.isArray(value.history.absentLl) && Array.isArray(value.history.provenance?.missingMembers), "private checkpoint source/history") + must(value.version === 2 ? Array.isArray(value.history.provenance.greMembers) : !value.history.provenance.missingMembers.some(row => row.type === "gre-tunnel" && !Object.hasOwn(row, "mac-address")), "incomplete legacy GRE provenance") const checkpoint: VrfProductionTransitionCheckpoint = Object.freeze({ kind: "vrf-production-transition-checkpoint-v1" }) histories.set(checkpoint, structuredClone(value.history)) return checkpoint @@ -55,13 +58,23 @@ export function vrfProductionCheckpointCatalogs(checkpoint: VrfProductionTransit } const cycleFields = [".id", "id", "type", "vrf", "disabled", "link-downs", "last-link-up-time", "last-link-down-time"] function witness(row: Row, fields: readonly string[]): Row { return Object.fromEntries(Object.entries(row).filter(([field]) => fields.includes(field))) } -function memberWitness(row: Row): Row { const result = witness(row, [".id", "name", "type", "mac-address"]); if (typeof result["mac-address"] === "string") result["mac-address"] = result["mac-address"].toLowerCase(); return result } +function memberWitness(row: Row, snapshot: VrfProductionSnapshot): Row { + const result = witness(row, [".id", "name", "type", "mac-address"]) + if (typeof result["mac-address"] === "string") result["mac-address"] = result["mac-address"].toLowerCase() + if (row.type === "gre-tunnel" && !Object.hasOwn(row, "mac-address")) result.greParent = greParent(snapshot, row) + return result +} /** Prior history is causal provenance, not a lease on the router's complete * configuration. The fresh approved ticket fences ALL current dependencies. */ export function assertVrfProductionHistoryProvenance(checkpoint: VrfProductionTransitionCheckpoint, before: VrfProductionSnapshot): void { const history = histories.get(checkpoint); must(history, "unknown private checkpoint") must(history.snapshot.serverId === before.serverId && history.snapshot.sourceProof === before.sourceProof, "history source changed") const target = before.catalogs.vrf.find(row => row[".id"] === history.vrfId) + must(history.provenance.greMembers !== undefined || !before.catalogs.interface_inventory.some(row => row.type === "gre-tunnel" && !Object.hasOwn(row, "mac-address") && target && members(target.interfaces).includes(String(row.name))), "incomplete legacy GRE provenance") + for (const expected of history.provenance.greMembers ?? []) { + const member = before.catalogs.interface_inventory.find(row => row[".id"] === expected[".id"]) + must(member && isDeepStrictEqual(memberWitness(member, before), expected), "GRE causal parent/endpoint changed") + } if (history.provenance.inactiveCycle) { const cycle = history.provenance.inactiveCycle, virtual = before.catalogs.interface_inventory.find(row => row[".id"] === cycle[".id"]) must(target && yes(target.disabled) && virtual && virtual.name === target.name, "inactive causal identity/context changed") @@ -71,13 +84,19 @@ export function assertVrfProductionHistoryProvenance(checkpoint: VrfProductionTr must(history.provenance.missingMembers.length === new Set(history.absentLl.map(item => item.member)).size, "missing LL causal witnesses") for (const expected of history.provenance.missingMembers) { const member = before.catalogs.interface_inventory.find(row => row[".id"] === expected[".id"]) - must(target && no(target.disabled) && member && isDeepStrictEqual(memberWitness(member), expected) && member.vrf === target.name && context(before.catalogs.vrf, String(member.name)) === target.name, "missing LL member causal identity/MAC/context changed") + must(target && no(target.disabled) && member && isDeepStrictEqual(memberWitness(member, before), expected) && member.vrf === target.name && context(before.catalogs.vrf, String(member.name)) === target.name, "missing LL member causal identity/MAC/context changed") must(!addressSources(before, [String(member.name)]).some(source => source.ll), "historical LL absence changed") } } const yes = (value: unknown) => [true, "true", "yes"].includes(value as string | boolean) const no = (value: unknown) => [false, "false", "no"].includes(value as string | boolean) -function must(value: unknown, reason: string): asserts value { if (!value) throw Error(`VRF production transition refused: ${reason}`) } +function must(value: unknown, reason: string, guard?: VrfTransitionGuard): asserts value { + if (!value) { + const error = Error(`VRF production transition refused: ${reason}`) + noteVrfTransitionRefusal(error, reason, guard) + throw error + } +} function nativeId(value: unknown): string { must(typeof value === "string" && /^\*[a-f0-9]+$/i.test(value), "native ID"); return value.slice(1).replace(/^0+/, "").toLowerCase() || "0" } const projected = vrfProductionConfiguration function same(resource: string, old: Row, current: Row, omit: readonly string[] = []): void { must(isDeepStrictEqual(projected(resource, old, omit), projected(resource, current, omit)), "opaque configuration drift") } @@ -103,7 +122,7 @@ function valid(snapshot: VrfProductionSnapshot): void { bytes += Buffer.byteLength(JSON.stringify(rows)); must(bytes < 32 * 1024 * 1024, "raw byte budget") } } -function snapshotProof(snapshot: VrfProductionSnapshot): SnapshotProof { +function snapshotProof(snapshot: VrfProductionSnapshot, kind: SnapshotProof["kind"] = "configuration-sha256-v2"): SnapshotProof { let hash = createHash("sha256") const feed = (value: unknown): void => { if (Array.isArray(value)) { hash.update("["); for (const child of value) { feed(child); hash.update(",") } hash.update("]"); return } @@ -111,11 +130,13 @@ function snapshotProof(snapshot: VrfProductionSnapshot): SnapshotProof { hash.update(JSON.stringify(value)) } const catalogDigests: Record = {} - for (const resource of Object.keys(snapshot.catalogs).sort()) { hash = createHash("sha256").update("mmapp-vrf-production-full-catalog-v1\0"); feed(resource); feed(snapshot.catalogs[resource].map(row => projected(resource, row))); catalogDigests[resource] = hash.digest("hex") } - return { kind: "configuration-sha256-v1", serverId: snapshot.serverId, sourceProof: snapshot.sourceProof, catalogDigests } + const projection = kind === "configuration-sha256-v1" ? vrfProductionConfigurationV2 : projected + const domain = kind === "configuration-sha256-v1" ? "mmapp-vrf-production-full-catalog-v1\0" : "mmapp-vrf-production-full-catalog-v2\0" + for (const resource of Object.keys(snapshot.catalogs).sort()) { hash = createHash("sha256").update(domain); feed(resource); feed(snapshot.catalogs[resource].map(row => projection(resource, row))); catalogDigests[resource] = hash.digest("hex") } + return { kind, serverId: snapshot.serverId, sourceProof: snapshot.sourceProof, catalogDigests } } function snapshotSame(old: VrfProductionSnapshot | SnapshotProof, current: VrfProductionSnapshot): void { - if (!("projection" in old)) { const next = snapshotProof(current); must(old.serverId === next.serverId && old.sourceProof === next.sourceProof && Object.entries(old.catalogDigests).every(([resource, digest]) => next.catalogDigests[resource] === digest), "checkpoint parent/configuration/order drift"); return } + if (!("projection" in old)) { const next = snapshotProof(current, old.kind); must(old.serverId === next.serverId && old.sourceProof === next.sourceProof && Object.entries(old.catalogDigests).every(([resource, digest]) => next.catalogDigests[resource] === digest), "checkpoint parent/configuration/order drift"); return } snapshotSame(snapshotProof(old), current) } function members(value: unknown): string[] { @@ -147,13 +168,50 @@ function context(vrfs: Row[], name: string): string { if (vrf.name === "main") { must(vrf.interfaces === "all" && no(vrf.disabled), "main VRF shape"); return "main" } if (no(vrf.disabled) && members(vrf.interfaces).includes(name)) return vrf.name } - throw Error("VRF production transition refused: missing ordered main VRF") + const error = Error("VRF production transition refused: missing ordered main VRF") + noteVrfTransitionRefusal(error, "missing ordered main VRF") + throw error } function eui64(row: Row, inventory: Row): void { must(yes(row.dynamic) && yes(row["auto-link-local"]) && yes(row["link-local"]) && row.vrf === "main" && typeof inventory["mac-address"] === "string" && /^(?:[a-f0-9]{2}:){5}[a-f0-9]{2}$/i.test(inventory["mac-address"]), "link-local parent flags/MAC") const mac = inventory["mac-address"].split(":").map(word => parseInt(word, 16)), bytes = [mac[0] ^ 2, mac[1], mac[2], 255, 254, mac[3], mac[4], mac[5]], words = ["fe80", "0", "0", "0", ...[0, 2, 4, 6].map(index => (bytes[index] * 256 + bytes[index + 1]).toString(16))] must(prefix(row.address, 6).length === 64 && prefix(row.address, 6).host && prefix(`${words.join(":")}/128`, 6).network === prefix(`${String(row.address).split("/")[0]}/128`, 6).network, "EUI64 link-local address") } +/** Bounded observed RouterOS IPv4 GRE auto-LL form, verified on four native + * GREs in the V29 capture. This is not a general IPv6 or tunnel derivation. + * The current concrete GRE and its explicit source parent remain mandatory. */ +function greParent(snapshot: VrfProductionSnapshot, inventory: Row): Row { + must(inventory.type === "gre-tunnel" && !Object.hasOwn(inventory, "mac-address"), "GRE native no-MAC parent") + const rows = snapshot.catalogs.gre?.filter(row => row.name === inventory.name) ?? [] + must(rows.length === 1, "GRE exact concrete parent") + const config = rows[0]; nativeId(config[".id"]); nativeId(inventory[".id"]) + const unicast4 = (value: unknown): value is string => typeof value === "string" && isIP(value) === 4 && Number(value.split(".")[0]) > 0 && Number(value.split(".")[0]) < 224 && value !== "255.255.255.255" + must(unicast4(config["local-address"]) && unicast4(config["remote-address"]), "GRE explicit IPv4 endpoints") + const sources = snapshot.catalogs.ipv4_address.filter(row => typeof row.address === "string" && row.address.split("/")[0] === config["local-address"]) + must(sources.length === 1 && no(sources[0].dynamic) && no(sources[0].disabled), "GRE exact static source") + const roots = snapshot.catalogs.interface_inventory.filter(row => row.name === sources[0].interface) + must(roots.length === 1 && roots[0][".id"] !== inventory[".id"], "GRE exact source parent") + const resources: Record = { ether: "ethernet", ethernet: "ethernet", bridge: "bridge", vlan: "vlan", wg: "wireguard", wireguard: "wireguard", "gre-tunnel": "gre", "ipip-tunnel": "ipip", "eoip-tunnel": "eoip", vxlan: "vxlan", bond: "bonding", bonding: "bonding", "pppoe-out": "pppoe_client" } + const resource = resources[String(roots[0].type)], configs = resource ? snapshot.catalogs[resource]?.filter(row => row.name === roots[0].name) ?? [] : [] + must(configs.length === 1, "GRE concrete source parent") + nativeId(roots[0][".id"]); nativeId(configs[0][".id"]) + // Durable causal history needs identity/endpoints, not opaque configuration + // or optional GRE secrets. Fresh admission/current reconciliation still fence + // the complete configuration separately. + return { configuration: witness(config, [".id", "name", "local-address", "remote-address"]), sourceAddress: witness(sources[0], [".id", "address", "interface", "actual-interface", "dynamic", "disabled", "vrf"]), sourceInventory: witness(roots[0], [".id", "name", "type", "mac-address"]), sourceConfiguration: witness(configs[0], [".id", "name"]), sourceResource: resource } +} +function nativeLinkLocal(snapshot: VrfProductionSnapshot, row: Row, inventory: Row): void { + if (inventory.type !== "gre-tunnel" || Object.hasOwn(inventory, "mac-address")) { eui64(row, inventory); return } + const parent = greParent(snapshot, inventory), config = parent.configuration as Row + must(yes(row.dynamic) && yes(row["auto-link-local"]) && yes(row["link-local"]) && no(row.disabled) && row.interface === inventory.name && row["actual-interface"] === inventory.name && no(row.invalid), "GRE auto-link-local native flags/identity") + const effectiveContext = context(snapshot.catalogs.vrf, String(inventory.name)) + must(inventory.vrf === effectiveContext, "GRE inventory effective context") + must(row.vrf === effectiveContext, "GRE link-local effective context") + const octets = String(config["remote-address"]).split(".").map(Number) + const expected = `fe80::2:${(octets[0] * 256 + octets[1]).toString(16)}:${(octets[2] * 256 + octets[3]).toString(16)}/64` + const parsed = prefix(row.address, 6) + must(parsed.length === 64 && prefix(`${parsed.host}/128`, 6).network === prefix(`${expected.split("/")[0]}/128`, 6).network, "GRE observed remote-bound auto-link-local") +} interface Source { resource: "ipv4_address" | "ipv6_address"; row: Row; member: string; network: string; family: 4 | 6; ll: boolean } function addressSources(snapshot: VrfProductionSnapshot, names: string[]): Source[] { const result: Source[] = [] @@ -164,7 +222,7 @@ function addressSources(snapshot: VrfProductionSnapshot, names: string[]): Sourc const parsed = prefix(row.address, family) if (ll) { must(family === 6, "dynamic non-link-local address") - const inventory = snapshot.catalogs.interface_inventory.filter(item => item.name === member); must(inventory.length === 1, "LL inventory"); eui64(row, inventory[0]) + const inventory = snapshot.catalogs.interface_inventory.filter(item => item.name === member); must(inventory.length === 1, "LL inventory"); nativeLinkLocal(snapshot, row, inventory[0]) } else must(no(row.dynamic) && row.vrf === context(snapshot.catalogs.vrf, member), "static address provenance/context") result.push({ resource, row, member, network: parsed.network, family, ll }) } @@ -180,6 +238,28 @@ function routeFor(row: Row, source: Source, vrfs: Row[]): boolean { if (row["routing-table"] !== table || row.gateway !== gateway || row["immediate-gw"] !== source.member || row.interface !== undefined && row.interface !== "" && row.interface !== source.member) return false return row["local-address"] === undefined || row["local-address"] === `${String(source.row.address).split("/")[0]}%${gateway}` } +/** A terminal DELETE may remove one proven automatic LL from an unchanged, + * empty concrete bridge. It creates no revival debt for the deleted target. */ +function deletedBridgeLinkLocal(before: VrfProductionSnapshot, after: VrfProductionSnapshot, old: Row, source: Source, oldRoute: Row, beforeSources: Source[], afterSources: Source[]): void { + const name = source.member + must(source.ll && source.family === 6 && no(old.disabled) && members(old.interfaces).includes(name) && context(before.catalogs.vrf, name) === old.name && context(after.catalogs.vrf, name) === "main" && !after.catalogs.vrf.some(row => row[".id"] === old[".id"]), "DELETE bridge LL owned context") + const parents = [before, after].map(snapshot => snapshot.catalogs.interface_inventory.filter(row => row.name === name)) + must(parents.every(rows => rows.length === 1) && parents.every(rows => rows[0].type === "bridge" && rows[0].disabled === "false" && rows[0].running === "true" && rows[0].dynamic === "false") && parents[0][0][".id"] === parents[1][0][".id"] && parents[0][0]["mac-address"] === parents[1][0]["mac-address"], "DELETE bridge LL concrete parent") + const parent = parents[0][0], id = parent[".id"] + for (const snapshot of [before, after]) { + const bridges = snapshot.catalogs.bridge.filter(row => row.name === name) + const reference = (value: unknown) => typeof value === "string" && value.split(",").some(item => [name, id].includes(item.trim())) + must(bridges.length === 1 && bridges[0][".id"] === id && bridges[0].disabled === "false" && bridges[0].dynamic === "false" && bridges[0].running === "true" && bridges[0]["mac-address"] === parent["mac-address"] && !snapshot.catalogs.bridge_port.some(row => reference(row.bridge) || reference(row.interface)) && !snapshot.catalogs.bridge_vlan.some(row => reference(row.bridge) || reference(row.tagged) || reference(row.untagged) || reference(row["current-tagged"]) || reference(row["current-untagged"])) && !(snapshot.catalogs.vlan ?? []).some(row => reference(row.interface)), "DELETE bridge LL empty configuration") + } + same("bridge", before.catalogs.bridge.find(row => row.name === name)!, after.catalogs.bridge.find(row => row.name === name)!) + must(source.row.dynamic === "true" && source.row["auto-link-local"] === "true" && source.row["link-local"] === "true" && source.row.disabled === "false" && source.row.invalid === "false" && source.row.interface === name && source.row["actual-interface"] === name, "DELETE bridge LL automatic provenance") + eui64(source.row, parent) + const statics = beforeSources.filter(item => item.member === name && !item.ll) + must(statics.some(item => item.family === 4) && statics.every(item => afterSources.some(next => next.member === name && next.resource === item.resource && !next.ll && next.row[".id"] === item.row[".id"] && next.network === item.network)) && !afterSources.some(item => item.member === name && item.ll), "DELETE bridge LL static address closure") + const routeReference = (row: Row) => [row.gateway, row["immediate-gw"], row.interface].some(value => typeof value === "string" && (value === name || value === id || value.startsWith(name + "@") || value.startsWith(String(id) + "@"))) + const linkLocalRoute = (row: Row) => { try { return prefix(row["dst-address"], 6).network === source.network && routeReference(row) } catch { return false } } + must(before.catalogs.ipv6_route.filter(linkLocalRoute).length === 1 && before.catalogs.ipv6_route.filter(linkLocalRoute)[0] === oldRoute && !after.catalogs.ipv6_route.some(linkLocalRoute), "DELETE bridge LL connected route closure") +} /** Existing object guards must see the exact concrete parent/dependent * identities already proved by admission, not a guessed numeric namespace. */ export function vrfProductionDependencyObjects(snapshot: VrfProductionSnapshot, admission: VrfProductionAdmission): Array<{ kind: string; id: string; serverId: number }> { @@ -205,6 +285,29 @@ export function vrfProductionDependencyObjects(snapshot: VrfProductionSnapshot, return [...objects.values()] } const timestamp = (value: unknown): value is string => typeof value === "string" && /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(value) && !Number.isNaN(Date.parse(value.replace(" ", "T") + "Z")) && new Date(value.replace(" ", "T") + "Z").toISOString().slice(0, 19).replace("T", " ") === value +const greMemberChronology = ["link-downs", "last-link-up-time", "last-link-down-time"] as const +/** One observed no-MAC GRE context-transfer cycle only. No chronology becomes + * telemetry: unchanged context and every other concrete type remain strict. */ +function memberInventoryOmissions(before: VrfProductionSnapshot, after: VrfProductionSnapshot, name: string, old: Row, current: Row): string[] { + const omit = ["vrf", "status"] + if (isDeepStrictEqual(witness(old, greMemberChronology), witness(current, greMemberChronology))) return omit + const previousContext = context(before.catalogs.vrf, name), nextContext = context(after.catalogs.vrf, name) + must(old.type === "gre-tunnel" && current.type === "gre-tunnel" && old.name === name && current.name === name && old[".id"] === current[".id"] && !Object.hasOwn(old, "mac-address") && !Object.hasOwn(current, "mac-address") && previousContext !== nextContext && old.vrf === previousContext && current.vrf === nextContext, "bounded GRE cycle member identity/context") + must(no(old.disabled) && no(current.disabled) && old.running === "true" && current.running === "true", "bounded GRE cycle active state") + const a = greParent(before, old), b = greParent(after, current) + const parent = (snapshot: VrfProductionSnapshot, resource: string, id: unknown) => snapshot.catalogs[resource]?.find(row => row[".id"] === id) + const oldGre = before.catalogs.gre.find(row => row.name === name), newGre = after.catalogs.gre.find(row => row.name === name) + const resource = String(a.sourceResource) + const oldRoot = parent(before, resource, (a.sourceConfiguration as Row)[".id"]), newRoot = parent(after, resource, (b.sourceConfiguration as Row)[".id"]) + const oldAddress = parent(before, "ipv4_address", (a.sourceAddress as Row)[".id"]), newAddress = parent(after, "ipv4_address", (b.sourceAddress as Row)[".id"]) + must(isDeepStrictEqual(a, b) && oldGre && newGre && oldRoot && newRoot && oldAddress && newAddress && isDeepStrictEqual(projected("gre", oldGre), projected("gre", newGre)) && isDeepStrictEqual(projected(resource, oldRoot), projected(resource, newRoot)) && isDeepStrictEqual(projected("ipv4_address", oldAddress), projected("ipv4_address", newAddress)), "bounded GRE cycle parent/configuration") + const oldCount = old["link-downs"], newCount = current["link-downs"] + // Twenty canonical digits are a local parsing budget, not a native bit-width claim. + must(typeof oldCount === "string" && /^(0|[1-9]\d{0,19})$/.test(oldCount) && typeof newCount === "string" && /^(0|[1-9]\d{0,19})$/.test(newCount) && BigInt(newCount) === BigInt(oldCount) + 1n, "bounded GRE cycle count") + const oldUp = old["last-link-up-time"], oldDown = old["last-link-down-time"], newDown = current["last-link-down-time"], newUp = current["last-link-up-time"] + must(timestamp(oldUp) && (oldDown === undefined || timestamp(oldDown)) && timestamp(newDown) && timestamp(newUp) && (oldDown === undefined || oldDown <= oldUp) && newDown >= oldUp && (oldDown === undefined || newDown >= oldDown) && newDown <= newUp, "bounded GRE cycle chronology") + return [...omit, ...greMemberChronology] +} function activeVirtual(row: Row): void { must(row.type === "vrf" && row.vrf === "main" && no(row.disabled) && row.running === "true" && row.status === "--LERU--O---------------" && row.flags === " UP RUNNING NOARP MASTER LOWER_UP (0x104c1)" && row["actual-mtu"] === "65536" && row.mtu === "65536" && typeof row.ifname === "string" && /^vrf\d+$/.test(row.ifname) && typeof row.ifindex === "string" && /^[1-9]\d*$/.test(row.ifindex) && typeof row["mac-address"] === "string" && /^(?:[a-f0-9]{2}:){5}[a-f0-9]{2}$/i.test(row["mac-address"]) && (parseInt(row["mac-address"].slice(0, 2), 16) & 3) === 2 && timestamp(row["last-link-up-time"]), "bounded native active virtual shape") } @@ -251,6 +354,7 @@ export function classifyVrfProductionTransition(input: VrfProductionTransitionIn const exemptBefore = new Map>(), exemptAfter = new Map>() const explain = (resource: string, previous: Row | undefined, next: Row | undefined) => { if (previous) { const rows = exemptBefore.get(resource) ?? new Set(); rows.add(previous); exemptBefore.set(resource, rows) } if (next) { const rows = exemptAfter.get(resource) ?? new Set(); rows.add(next); exemptAfter.set(resource, rows) } } explain("vrf", old, current) + withVrfTransitionSection("member-inventory", () => { for (const name of changes) { const a = before.catalogs.interface_inventory.filter(row => row.name === name), b = after.catalogs.interface_inventory.filter(row => row.name === name) // The signed admission already resolves exact concrete physical/VLAN/etc @@ -262,41 +366,52 @@ export function classifyVrfProductionTransition(input: VrfProductionTransitionIn const x = String(a[0].status), y = String(b[0].status) must(x.length === 24 && y.length === 24 && x.slice(0, 20) === y.slice(0, 20) && x.slice(21) === y.slice(21) && x[20] === (oldContext.get(name) === "main" ? "-" : "V") && y[20] === (newContext.get(name) === "main" ? "-" : "V"), "member status native shape") } - same("interface_inventory", a[0], b[0], ["vrf", "status"]); explain("interface_inventory", a[0], b[0]) + same("interface_inventory", a[0], b[0], memberInventoryOmissions(before, after, name, a[0], b[0])); explain("interface_inventory", a[0], b[0]) } - const beforeSources = addressSources(before, names), afterSources = addressSources(after, names), absences: History["absentLl"] = [] + }) + const beforeSources = withVrfTransitionSection("before-address", () => addressSources(before, names)) + const afterSources = withVrfTransitionSection("after-address", () => addressSources(after, names)) + const absences: History["absentLl"] = [] const routeResource = (source: Source) => source.family === 4 ? "ipv4_route" : "ipv6_route" - const route = (snapshot: VrfProductionSnapshot, source: Source): Row => { const matches = snapshot.catalogs[routeResource(source)].filter(row => routeFor(row, source, snapshot.catalogs.vrf)); must(matches.length === 1, "missing/ambiguous proven connected route"); return matches[0] } + const route = (snapshot: VrfProductionSnapshot, source: Source): Row => withVrfTransitionSection("connected-route", () => { const matches = snapshot.catalogs[routeResource(source)].filter(row => routeFor(row, source, snapshot.catalogs.vrf)); must(matches.length === 1, "missing/ambiguous proven connected route"); return matches[0] }) const usedAfter = new Set() for (const source of beforeSources) { const successors = afterSources.filter(candidate => candidate.member === source.member && candidate.network === source.network && candidate.ll === source.ll) must(successors.length <= 1, "ambiguous address successor") const successor = successors[0], oldRoute = route(before, source) if (!successor) { + if (request.action === "delete" && source.ll) { + withVrfTransitionSection("LL-comparison", () => deletedBridgeLinkLocal(before, after, old!, source, oldRoute, beforeSources, afterSources)) + explain(source.resource, source.row, undefined); explain(routeResource(source), oldRoute, undefined); continue + } must(source.ll && transfer && current!.interfaces === source.member && oldContext.get(source.member) === "main", "address/route absence outside exact acquisition") absences.push({ address: structuredClone(source.row), route: structuredClone(oldRoute), member: source.member }); explain(source.resource, source.row, undefined); explain(routeResource(source), oldRoute, undefined); continue } usedAfter.add(successor) - if (source.ll) same(source.resource, source.row, successor.row, [".id"]) + const moved = changes.includes(source.member) + if (source.ll) { + const concreteNoMacGre = [before, after].every(snapshot => snapshot.catalogs.interface_inventory.some(row => row.name === source.member && row.type === "gre-tunnel" && !Object.hasOwn(row, "mac-address"))) + withVrfTransitionSection("LL-comparison", () => same(source.resource, source.row, successor.row, moved && concreteNoMacGre ? [".id", "vrf"] : [".id"])) + } else { must(source.row[".id"] === successor.row[".id"], "static address identity changed"); same(source.resource, source.row, successor.row, ["vrf"]) } const newRoute = route(after, successor) - const moved = changes.includes(source.member) must(Object.hasOwn(oldRoute, "local-address") === Object.hasOwn(newRoute, "local-address"), "connected route local-address shape changed") const ownedDelete = request.action === "delete" && oldContext.get(source.member) === old!.name - same(routeResource(source), oldRoute, newRoute, moved ? [".id", "gateway", "immediate-gw", "routing-table", "local-address"] : transfer || ownedDelete ? [".id"] : []) + withVrfTransitionSection("connected-route", () => same(routeResource(source), oldRoute, newRoute, moved ? [".id", "gateway", "immediate-gw", "routing-table", "local-address"] : transfer || ownedDelete ? [".id"] : [])) if (source.ll && source.row[".id"] !== successor.row[".id"]) must(moved || transfer || ownedDelete, "LL ID changed outside native move") explain(source.resource, source.row, successor.row); explain(routeResource(source), oldRoute, newRoute) } for (const successor of afterSources.filter(source => !usedAfter.has(source))) { const missing = history?.absentLl.filter(item => item.member === successor.member) ?? [] must(request.action === "delete" && successor.ll && missing.length === 1, "new address/route adoption forbidden") - same(successor.resource, missing[0].address, successor.row, [".id"]); const nextRoute = route(after, successor) + withVrfTransitionSection("LL-comparison", () => same(successor.resource, missing[0].address, successor.row, [".id"])); const nextRoute = route(after, successor) must(Object.hasOwn(missing[0].route, "local-address") === Object.hasOwn(nextRoute, "local-address"), "revived route local-address shape") same(routeResource(successor), missing[0].route, nextRoute, [".id", "gateway", "immediate-gw", "routing-table", "local-address"]) explain(successor.resource, undefined, successor.row); explain(routeResource(successor), undefined, nextRoute) } for (const missing of history?.absentLl ?? []) if (!afterSources.some(source => source.member === missing.member && source.ll)) { must(request.action !== "delete", "DELETE missing expected LL revival"); absences.push(structuredClone(missing)) } let active: Row | undefined, dormant: Row | undefined + withVrfTransitionSection("virtual", () => { for (const resource of ["routing_table", "interface_inventory"]) { const find = (snapshot: VrfProductionSnapshot, vrf: Row | undefined): Row | undefined => { if (!vrf) return undefined @@ -338,17 +453,23 @@ export function classifyVrfProductionTransition(input: VrfProductionTransitionIn } explain(resource, a, b) } + }) // No explained successor may acquire an ID belonging to a foreign row. + withVrfTransitionSection("foreign", () => { for (const resource of Object.keys(before.catalogs)) { const a = exemptBefore.get(resource) ?? new Set(), b = exemptAfter.get(resource) ?? new Set() const foreignBefore = before.catalogs[resource].filter(row => !a.has(row)), foreignAfter = after.catalogs[resource].filter(row => !b.has(row)) - must(isDeepStrictEqual(foreignBefore.map(row => projected(resource, row)), foreignAfter.map(row => projected(resource, row))), `foreign ${resource} configuration/order changed`) + must(isDeepStrictEqual(foreignBefore.map(row => projected(resource, row)), foreignAfter.map(row => projected(resource, row))), `foreign ${resource} configuration/order changed`, "FOREIGN_CATALOG_CONFIGURATION_ORDER_CHANGED") for (const row of b) must(!foreignBefore.some(previous => nativeId(previous[".id"]) === nativeId(row[".id"])), "companion acquired foreign ID") } + }) const checkpoint: VrfProductionTransitionCheckpoint = Object.freeze({ kind: "vrf-production-transition-checkpoint-v1" }) const provenance: CausalProvenance = { missingMembers: [...new Set(absences.map(item => item.member))].map(name => { const inventory = after.catalogs.interface_inventory.find(row => row.name === name); must(inventory, "missing LL causal member") - return memberWitness(inventory) + return memberWitness(inventory, after) + }), greMembers: [...new Set([...names, ...(history?.provenance.greMembers ?? []).map(row => String(row.name))])].flatMap(name => { + const inventory = after.catalogs.interface_inventory.find(row => row.name === name) + return inventory?.type === "gre-tunnel" && !Object.hasOwn(inventory, "mac-address") ? [memberWitness(inventory, after)] : [] }) } if (current && yes(current.disabled) && (active || dormant)) { const virtual = after.catalogs.interface_inventory.find(row => row.type === "vrf" && row.name === current.name && row.vrf === "main") diff --git a/backend/src/services/vrf-transition-refusal.ts b/backend/src/services/vrf-transition-refusal.ts new file mode 100644 index 00000000..9bd95b99 --- /dev/null +++ b/backend/src/services/vrf-transition-refusal.ts @@ -0,0 +1,148 @@ +import { types } from "node:util" + +// Explicit immutable code table, never derive a tag from +// a message, stack, native value, dynamic resource name or caller JSON. +export const VRF_TRANSITION_REASON_CODES = Object.freeze({ + "DELETE bridge LL owned context": "DELETE_BRIDGE_LL_OWNED_CONTEXT", + "DELETE bridge LL concrete parent": "DELETE_BRIDGE_LL_CONCRETE_PARENT", + "DELETE bridge LL empty configuration": "DELETE_BRIDGE_LL_EMPTY_CONFIGURATION", + "DELETE bridge LL automatic provenance": "DELETE_BRIDGE_LL_AUTOMATIC_PROVENANCE", + "DELETE bridge LL static address closure": "DELETE_BRIDGE_LL_STATIC_ADDRESS_CLOSURE", + "DELETE bridge LL connected route closure": "DELETE_BRIDGE_LL_CONNECTED_ROUTE_CLOSURE", + "bounded GRE cycle member identity/context": "BOUNDED_GRE_CYCLE_MEMBER_IDENTITY_CONTEXT", + "bounded GRE cycle active state": "BOUNDED_GRE_CYCLE_ACTIVE_STATE", + "bounded GRE cycle parent/configuration": "BOUNDED_GRE_CYCLE_PARENT_CONFIGURATION", + "bounded GRE cycle count": "BOUNDED_GRE_CYCLE_COUNT", + "bounded GRE cycle chronology": "BOUNDED_GRE_CYCLE_CHRONOLOGY", + "CIDR": "CIDR", + "CREATE exact new ACK identity": "CREATE_EXACT_NEW_ACK_IDENTITY", + "CREATE exact ordered insertion": "CREATE_EXACT_ORDERED_INSERTION", + "DELETE exact target": "DELETE_EXACT_TARGET", + "DELETE missing expected LL revival": "DELETE_MISSING_EXPECTED_LL_REVIVAL", + "EUI64 link-local address": "EUI64_LINK_LOCAL_ADDRESS", + "GRE auto-link-local native flags/identity": "GRE_AUTO_LINK_LOCAL_NATIVE_FLAGS_IDENTITY", + "GRE causal parent/endpoint changed": "GRE_CAUSAL_PARENT_ENDPOINT_CHANGED", + "GRE concrete source parent": "GRE_CONCRETE_SOURCE_PARENT", + "GRE exact concrete parent": "GRE_EXACT_CONCRETE_PARENT", + "GRE exact source parent": "GRE_EXACT_SOURCE_PARENT", + "GRE exact static source": "GRE_EXACT_STATIC_SOURCE", + "GRE explicit IPv4 endpoints": "GRE_EXPLICIT_IPV4_ENDPOINTS", + "GRE inventory effective context": "GRE_INVENTORY_EFFECTIVE_CONTEXT", + "GRE link-local effective context": "GRE_LINK_LOCAL_EFFECTIVE_CONTEXT", + "GRE native no-MAC parent": "GRE_NATIVE_NO_MAC_PARENT", + "GRE observed remote-bound auto-link-local": "GRE_OBSERVED_REMOTE_BOUND_AUTO_LINK_LOCAL", + "IPv6 words": "IPV6_WORDS", + "JSON nesting budget": "JSON_NESTING_BUDGET", + "LL ID changed outside native move": "LL_ID_CHANGED_OUTSIDE_NATIVE_MOVE", + "LL inventory": "LL_INVENTORY", + "PATCH exact target/order": "PATCH_EXACT_TARGET_ORDER", + "VRF identity/enabled state": "VRF_IDENTITY_ENABLED_STATE", + "VRF table exact native parent": "VRF_TABLE_EXACT_NATIVE_PARENT", + "address context": "ADDRESS_CONTEXT", + "address/route absence outside exact acquisition": "ADDRESS_ROUTE_ABSENCE_OUTSIDE_EXACT_ACQUISITION", + "after source/catalog set changed": "AFTER_SOURCE_CATALOG_SET_CHANGED", + "ambiguous ID aliases": "AMBIGUOUS_ID_ALIASES", + "ambiguous address successor": "AMBIGUOUS_ADDRESS_SUCCESSOR", + "ambiguous equal-prefix address sources": "AMBIGUOUS_EQUAL_PREFIX_ADDRESS_SOURCES", + "ambiguous members": "AMBIGUOUS_MEMBERS", + "ambiguous target companion": "AMBIGUOUS_TARGET_COMPANION", + "bounded native active virtual shape": "BOUNDED_NATIVE_ACTIVE_VIRTUAL_SHAPE", + "bounded native inactive virtual shape": "BOUNDED_NATIVE_INACTIVE_VIRTUAL_SHAPE", + "bounded virtual disable shape": "BOUNDED_VIRTUAL_DISABLE_SHAPE", + "catalog budget": "CATALOG_BUDGET", + "checkpoint parent/configuration/order drift": "CHECKPOINT_PARENT_CONFIGURATION_ORDER_DRIFT", + "checkpoint scope": "CHECKPOINT_SCOPE", + "checkpoint target changed": "CHECKPOINT_TARGET_CHANGED", + "companion acquired foreign ID": "COMPANION_ACQUIRED_FOREIGN_ID", + "complete raw17 snapshot": "COMPLETE_RAW17_SNAPSHOT", + "connected route local-address shape changed": "CONNECTED_ROUTE_LOCAL_ADDRESS_SHAPE_CHANGED", + "dependency connected route identity": "DEPENDENCY_CONNECTED_ROUTE_IDENTITY", + "dependency object source": "DEPENDENCY_OBJECT_SOURCE", + "disabled/deleted table unexpectedly present": "DISABLED_DELETED_TABLE_UNEXPECTEDLY_PRESENT", + "disabled/unknown selected address unsupported": "DISABLED_UNKNOWN_SELECTED_ADDRESS_UNSUPPORTED", + "dynamic non-link-local address": "DYNAMIC_NON_LINK_LOCAL_ADDRESS", + "foreign VRF configuration/order changed": "FOREIGN_VRF_CONFIGURATION_ORDER_CHANGED", + "historical LL absence changed": "HISTORICAL_LL_ABSENCE_CHANGED", + "history source changed": "HISTORY_SOURCE_CHANGED", + "inactive causal cycle/table absence changed": "INACTIVE_CAUSAL_CYCLE_TABLE_ABSENCE_CHANGED", + "inactive causal identity/context changed": "INACTIVE_CAUSAL_IDENTITY_CONTEXT_CHANGED", + "inactive virtual chronology": "INACTIVE_VIRTUAL_CHRONOLOGY", + "incomplete legacy GRE provenance": "INCOMPLETE_LEGACY_GRE_PROVENANCE", + "link-local parent flags/MAC": "LINK_LOCAL_PARENT_FLAGS_MAC", + "main VRF shape": "MAIN_VRF_SHAPE", + "mapped IPv6 alias": "MAPPED_IPV6_ALIAS", + "member concrete type/parent identity changed": "MEMBER_CONCRETE_TYPE_PARENT_IDENTITY_CHANGED", + "member native VRF context": "MEMBER_NATIVE_VRF_CONTEXT", + "member status native shape": "MEMBER_STATUS_NATIVE_SHAPE", + "missing LL causal member": "MISSING_LL_CAUSAL_MEMBER", + "missing LL causal witnesses": "MISSING_LL_CAUSAL_WITNESSES", + "missing LL member causal identity/MAC/context changed": "MISSING_LL_MEMBER_CAUSAL_IDENTITY_MAC_CONTEXT_CHANGED", + "missing inactive causal virtual": "MISSING_INACTIVE_CAUSAL_VIRTUAL", + "missing ordered main VRF": "MISSING_ORDERED_MAIN_VRF", + "missing primary ACK target ID": "MISSING_PRIMARY_ACK_TARGET_ID", + "missing prior virtual interface": "MISSING_PRIOR_VIRTUAL_INTERFACE", + "missing resulting virtual interface": "MISSING_RESULTING_VIRTUAL_INTERFACE", + "missing/ambiguous proven connected route": "MISSING_AMBIGUOUS_PROVEN_CONNECTED_ROUTE", + "native ID": "NATIVE_ID", + "native row": "NATIVE_ROW", + "new address/route adoption forbidden": "NEW_ADDRESS_ROUTE_ADOPTION_FORBIDDEN", + "new table unknown fields": "NEW_TABLE_UNKNOWN_FIELDS", + "new virtual unknown fields/defaults/generated identity": "NEW_VIRTUAL_UNKNOWN_FIELDS_DEFAULTS_GENERATED_IDENTITY", + "newly enabled table unknown fields": "NEWLY_ENABLED_TABLE_UNKNOWN_FIELDS", + "non-JSON raw value": "NON_JSON_RAW_VALUE", + "opaque configuration drift": "OPAQUE_CONFIGURATION_DRIFT", + "prefix length": "PREFIX_LENGTH", + "private checkpoint budget": "PRIVATE_CHECKPOINT_BUDGET", + "private checkpoint budget/key": "PRIVATE_CHECKPOINT_BUDGET_KEY", + "private checkpoint encoding": "PRIVATE_CHECKPOINT_ENCODING", + "private checkpoint scope/version": "PRIVATE_CHECKPOINT_SCOPE_VERSION", + "private checkpoint signature": "PRIVATE_CHECKPOINT_SIGNATURE", + "private checkpoint source/history": "PRIVATE_CHECKPOINT_SOURCE_HISTORY", + "raw byte budget": "RAW_BYTE_BUDGET", + "reenable generated identity/chronology": "REENABLE_GENERATED_IDENTITY_CHRONOLOGY", + "requested primary field mismatch": "REQUESTED_PRIMARY_FIELD_MISMATCH", + "revived route local-address shape": "REVIVED_ROUTE_LOCAL_ADDRESS_SHAPE", + "singleton cardinality": "SINGLETON_CARDINALITY", + "static address identity changed": "STATIC_ADDRESS_IDENTITY_CHANGED", + "static address provenance/context": "STATIC_ADDRESS_PROVENANCE_CONTEXT", + "unknown member selector": "UNKNOWN_MEMBER_SELECTOR", + "unknown private checkpoint": "UNKNOWN_PRIVATE_CHECKPOINT", + "unrecognized serialized checkpoint": "UNRECOGNIZED_SERIALIZED_CHECKPOINT", + "unrequested enabled transition": "UNREQUESTED_ENABLED_TRANSITION", + "unreviewed CREATE defaults": "UNREVIEWED_CREATE_DEFAULTS", + "virtual interface ID changed": "VIRTUAL_INTERFACE_ID_CHANGED" +} as const) +export type VrfTransitionGuard = typeof VRF_TRANSITION_REASON_CODES[keyof typeof VRF_TRANSITION_REASON_CODES] | "FOREIGN_CATALOG_CONFIGURATION_ORDER_CHANGED" +export const VRF_TRANSITION_GUARDS = [...Object.values(VRF_TRANSITION_REASON_CODES), "FOREIGN_CATALOG_CONFIGURATION_ORDER_CHANGED"] as const +export const VRF_TRANSITION_SECTIONS = ["member-inventory", "before-address", "after-address", "LL-comparison", "connected-route", "virtual", "foreign"] as const +export type VrfTransitionSection = typeof VRF_TRANSITION_SECTIONS[number] +export interface VrfTransitionRefusalTag { guard: VrfTransitionGuard; section?: VrfTransitionSection } +const guards = new Set(VRF_TRANSITION_GUARDS), sections = new Set(VRF_TRANSITION_SECTIONS) +const owned = new WeakMap>() +/** Internal transition must() calls this immediately after creating its Error. + * Preserve that exact Error and its properties; no replacement/wrapping. */ +export function noteVrfTransitionRefusal(error: Error, reason: string, explicit?: VrfTransitionGuard): void { + try { + if (typeof reason !== "string") return + const guard = explicit ?? (Object.hasOwn(VRF_TRANSITION_REASON_CODES, reason) ? VRF_TRANSITION_REASON_CODES[reason as keyof typeof VRF_TRANSITION_REASON_CODES] : undefined) + if (types.isNativeError(error) && guard && guards.has(guard)) owned.set(error, Object.freeze({ guard })) + } catch { /* Diagnostic recording cannot replace the original refusal. */ } +} +export function readVrfTransitionRefusal(error: unknown): VrfTransitionRefusalTag | undefined { + if (!error || typeof error !== "object") return undefined + const value = owned.get(error) + return value ? { ...value } : undefined +} +/** Synchronous pure classifier sections only. Never tag an unowned error; keep + * the innermost established section and rethrow the original object unchanged. */ +export function withVrfTransitionSection(section: VrfTransitionSection, work: () => T): T { + try { return work() } catch (error) { + try { + if (error && typeof error === "object") { + const value = owned.get(error) + if (value && !value.section && sections.has(section)) owned.set(error, Object.freeze({ ...value, section })) + } + } catch { /* Never replace the original error. */ } + throw error + } +} diff --git a/backend/src/services/wireguard-dependency-read.test.ts b/backend/src/services/wireguard-dependency-read.test.ts index c3bf3bee..c2f1a03c 100644 --- a/backend/src/services/wireguard-dependency-read.test.ts +++ b/backend/src/services/wireguard-dependency-read.test.ts @@ -43,15 +43,24 @@ function fixture(t: TestContext, outcomes: Outcome[]) { } } -test("dependency reset retries the same GET with fresh authority, reduced time, and one capture slot", async t => { - const { client, paths, timeouts } = fixture(t, ["reset", "ok", "reset"]) +test("two distinct dependency resets can retry with fresh authority and the original read deadlines", async t => { + const { client, paths, timeouts } = fixture(t, ["reset", "ok", "reset", "ok", "reset"]) let authorized = 0, clock = 0 const read = createWireguardDependencyReader(client, async () => { if (++authorized === 2) clock += 120 }, () => clock) assert.deepEqual(await read("/interface"), []) - await assert.rejects(read("/routing/ospf/neighbor"), { code: "STATE_UNAVAILABLE" }) - assert.equal(authorized, 3) - assert.deepEqual(paths, ["/rest/interface", "/rest/interface", "/rest/routing/ospf/neighbor"]) - assert.deepEqual(timeouts, [10_000, 9_880, 10_000]) + assert.deepEqual(await read("/routing/ospf/neighbor"), []) + await assert.rejects(read("/ip/route"), { code: "STATE_UNAVAILABLE" }) + assert.equal(authorized, 5) + assert.deepEqual(paths, ["/rest/interface", "/rest/interface", "/rest/routing/ospf/neighbor", "/rest/routing/ospf/neighbor", "/rest/ip/route"]) + assert.deepEqual(timeouts, [10_000, 9_880, 10_000, 10_000, 10_000]) +}) + +test("the same catalogue cannot use the second retry slot", async t => { + const { client, paths } = fixture(t, ["reset", "reset", "reset"]) + const read = createWireguardDependencyReader(client, async () => {}) + await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" }) + await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" }) + assert.equal(paths.length, 3) }) test("post-header reset, refusal, malformed body and unproven reset cannot replay", async t => { @@ -67,13 +76,13 @@ test("post-header reset, refusal, malformed body and unproven reset cannot repla assert.equal(calls, 1) }) -test("base IPv4 and IPv6 address reads never retry or use the dependency slot", async t => { - const { client, paths } = fixture(t, ["reset", "reset", "reset", "ok"]) +test("IPv4 and IPv6 reads share the dependency retry budget", async t => { + const { client, paths } = fixture(t, ["reset", "ok", "reset", "ok", "reset"]) const read = createWireguardDependencyReader(client, async () => {}) - await assert.rejects(read("/ip/address"), { code: "STATE_UNAVAILABLE" }) - await assert.rejects(read("/ipv6/address"), { code: "STATE_UNAVAILABLE" }) - assert.deepEqual(await read("/interface"), []) - assert.deepEqual(paths, ["/rest/ip/address", "/rest/ipv6/address", "/rest/interface", "/rest/interface"]) + assert.deepEqual(await read("/ip/address"), []) + assert.deepEqual(await read("/ipv6/address"), []) + await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" }) + assert.deepEqual(paths, ["/rest/ip/address", "/rest/ip/address", "/rest/ipv6/address", "/rest/ipv6/address", "/rest/interface"]) }) test("retry authority denial and deadline expiry stop before transport", async t => { diff --git a/backend/src/services/wireguard-interface-delete.ts b/backend/src/services/wireguard-interface-delete.ts index 6b7f5814..62b143fe 100644 --- a/backend/src/services/wireguard-interface-delete.ts +++ b/backend/src/services/wireguard-interface-delete.ts @@ -46,20 +46,22 @@ function nativeRows(raw: unknown, required: string[] = []): Row[] { return raw as Row[] } -/** Share one retry across this dependency capture and renew source authority before using it. */ +/** Retry at most two distinct catalogue GETs per capture, with fresh source authority. */ export function createWireguardDependencyReader(client: Pick, authorizeSource: () => Promise, now = () => performance.now()) { const timeoutMs = 10_000, maxBytes = 1_048_576 const unavailable = () => fail("STATE_UNAVAILABLE", "Не прочитан обязательный источник зависимостей WireGuard") - let retried = false + const retriedPaths = new Set() return async (path: string): Promise => { await authorizeSource() const deadline = now() + timeoutMs try { return await client.get(path, timeoutMs, undefined, maxBytes) } catch (error) { - if (retried || !wireguardInterfaceDependencyPaths.includes(path) || !isRouterOsPreHeaderGetReset(error) || now() >= deadline) throw unavailable() + const retryableCatalogue = path === "/ip/address" || path === "/ipv6/address" || wireguardInterfaceDependencyPaths.includes(path) + if (retriedPaths.size >= 2 || retriedPaths.has(path) || !retryableCatalogue + || !isRouterOsPreHeaderGetReset(error) || now() >= deadline) throw unavailable() } - retried = true + retriedPaths.add(path) await authorizeSource() const remaining = Math.floor(deadline - now()) if (remaining < 1) throw unavailable() diff --git a/backend/src/services/wireguard-peer-delete.ts b/backend/src/services/wireguard-peer-delete.ts index 3e030198..a9a7fa76 100644 --- a/backend/src/services/wireguard-peer-delete.ts +++ b/backend/src/services/wireguard-peer-delete.ts @@ -1,7 +1,7 @@ import { pool } from "../db/index.js" import { getServerRowById, type ServerRow } from "../modules/servers/repository/servers-repository.js" import { fingerprint, McpError, privateFingerprinter } from "../modules/mcp/security.js" -import { MikrotikClient } from "./mikrotik.js" +import { isRouterOsPreHeaderGetReset, MikrotikClient } from "./mikrotik.js" import { observedBoolean } from "./ospf-observed-values.js" import { withApplicationMutationLock, withConfigMutationLock } from "./config-mutation-lock.js" import { captureAndAppendRevision } from "./config-revisions.js" @@ -56,9 +56,21 @@ export function wireguardPeerDeletionTarget(state: WireguardPeerDeletionState, i export async function readWireguardPeerDeletionState(server: ServerRow, beforeRead: () => Promise = async () => {}): Promise { const sign = await privateFingerprinter("wireguard-peer-delete-v1"), serverProof = sign(fingerprint(wireguardEndpointSource(server))) const client = MikrotikClient.fromServer(server) + let catalogueRetryUsed = false const read = async (path: string) => { await source(server, beforeRead) + const deadline = performance.now() + 10_000 try { return await client.get(path, 10000, undefined, 1048576) } + catch (error) { + if (catalogueRetryUsed || !isRouterOsPreHeaderGetReset(error) || performance.now() >= deadline) { + throw problem("Native-каталог WireGuard недоступен", "STATE_UNAVAILABLE") + } + } + catalogueRetryUsed = true + await source(server, beforeRead) + const remaining = Math.floor(deadline - performance.now()) + if (remaining < 1) throw problem("Native-каталог WireGuard недоступен", "STATE_UNAVAILABLE") + try { return await client.get(path, remaining, undefined, 1048576) } catch { throw problem("Native-каталог WireGuard недоступен", "STATE_UNAVAILABLE") } } const rawInterfaces = await read("/interface/wireguard"), rawPeers = await read("/interface/wireguard/peers") diff --git a/backend/src/test/mcp-bridge-vlan-configuration.ts b/backend/src/test/mcp-bridge-vlan-configuration.ts new file mode 100644 index 00000000..f0c74d2d --- /dev/null +++ b/backend/src/test/mcp-bridge-vlan-configuration.ts @@ -0,0 +1,351 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" +import { createServer } from "node:http" +import { setTimeout as delay } from "node:timers/promises" +import { SignJWT } from "jose" +import type { FastifyInstance } from "fastify" +import type { BridgeVlanDesired, BridgeVlanState } from "@mmapp/contracts/bridge-vlan" +import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp" +import { env } from "../config.js" +import { pool } from "../db/index.js" +import type { AuthUser } from "../lib/permissions.js" +import { accessGeneration, decryptSecret, encryptSecret, fingerprint } from "../modules/mcp/security.js" +import { bridgeVlanCurrentDesired } from "../services/bridge-vlan-configuration.js" +import { createMcpConnection, authenticateMcpToken, setConnectionStatus } from "../modules/mcp/store.js" +import { invokeMcpTool, approvePlan } from "../modules/mcp/runtime.js" + +/** Synthetic SQL and two HTTP loopback endpoints only. The sealed channel is + * seeded: this does not claim channel registration, TLS or network independence. */ +export async function verifyMcpBridgeVlanConfiguration(actor: AuthUser, app: FastifyInstance) { + const serverId = 91801, channelId = randomUUID(), connections: string[] = [], pending = new Set() + type Row = Record + type Rows = Record + const initial: Rows = { + "/interface/ethernet": [ + { ".id": "*2", name: "ether2", disabled: "false" }, { ".id": "*3", name: "ether3", disabled: "false" }, + { ".id": "*4", name: "ether-mgmt", disabled: "false" }, { ".id": "*9", name: "ether9", disabled: "false" }, + ], + "/interface/bridge": [{ ".id": "*1", name: "br-users", pvid: "1", "vlan-filtering": "false", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false", disabled: "false", opaque: "preserve-bridge" }, { ".id": "*8", name: "br-foreign", pvid: "1", "vlan-filtering": "false", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false", disabled: "false" }], + "/interface/bridge/port": [{ ".id": "*10", bridge: "br-users", interface: "ether2", pvid: "10", "ingress-filtering": "true", "frame-types": "admit-all", disabled: "false", opaque: "preserve-port" }, { ".id": "*98", bridge: "br-foreign", interface: "ether9", pvid: "99", "ingress-filtering": "true", "frame-types": "admit-all", disabled: "false" }], + "/interface/bridge/vlan": [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users", untagged: "ether2", disabled: "false", dynamic: "false", comment: "existing-membership", opaque: "preserve-static" }, { ".id": "*99", bridge: "br-foreign", "vlan-ids": "99", tagged: "br-foreign", untagged: "ether9", dynamic: "false", disabled: "false" }], + "/interface/vlan": [], "/interface/gre": [], "/interface/wireguard": [], "/interface/vxlan": [], "/ip/vrf": [], + "/ip/address": [{ ".id": "*20", interface: "ether-mgmt", address: "127.0.0.1/8", disabled: "false", dynamic: "false" }], "/ipv6/address": [], + } + initial["/interface/bridge/vlan"].push({ ".id": "*30", bridge: "br-users", "vlan-ids": "10", dynamic: "true", "current-untagged": "ether2", disabled: "false" }) + let rows = structuredClone(initial), commands: string[] = [], nextId = 160, lostAck = false, managementReads = 0, primaryIdentityReads = 0 + let beforePrimaryIdentity: (() => void) | undefined + const router = createServer(async (req, res) => { + res.setHeader("content-type", "application/json") + const path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, "")) + if (req.method === "GET") { + if (path === "/system/license") { + primaryIdentityReads++ + if (beforePrimaryIdentity) { const run = beforePrimaryIdentity; beforePrimaryIdentity = undefined; run() } + res.end(JSON.stringify({ "system-id": "synthetic-bridge-device" })); return + } + if (path === "/interface") { res.end(JSON.stringify(Object.entries(rows).filter(([key]) => ["/interface/ethernet", "/interface/bridge"].includes(key)).flatMap(([key, values]) => values.map(row => ({ ...row, type: key.endsWith("ethernet") ? "ether" : "bridge" }))))); return } + if (!Object.hasOwn(rows, path)) { res.statusCode = 404; res.end("{}"); return } + res.end(JSON.stringify(rows[path])); return + } + const base = Object.keys(rows).sort((a, b) => b.length - a.length).find(key => path === key || path.startsWith(key + "/")) + assert.ok(base, "Only declared fixture collections may be mutated") + let raw = ""; for await (const chunk of req) raw += chunk + const input = raw ? JSON.parse(raw) : {} + const fields = Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value === "yes" ? "true" : value === "no" ? "false" : String(value)])) + commands.push(req.method + " " + path) + if (req.method === "PUT") { + const row = { ".id": "*" + (++nextId).toString(16), ...fields, dynamic: "false" }; rows[base].push(row) + if (lostAck) { lostAck = false; req.socket.destroy(); return } + res.end(JSON.stringify(row)); return + } + if (req.method === "PATCH") { const row = rows[base].find(row => row[".id"] === path.slice(base.length + 1)); assert.ok(row); Object.assign(row, fields); res.end(JSON.stringify(row)); return } + if (req.method === "DELETE") { const id = path.slice(base.length + 1); assert.ok(rows[base].some(row => row[".id"] === id)); rows[base] = rows[base].filter(row => row[".id"] !== id); res.end("{}"); return } + res.statusCode = 405; res.end("{}") + }) + const management = createServer((req, res) => { + managementReads++; assert.equal(req.method, "GET"); assert.equal(req.url, "/rest/system/license") + res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ "system-id": "synthetic-bridge-device" })) + }) + await Promise.all([new Promise(resolve => router.listen(0, "127.0.0.1", resolve)), new Promise(resolve => management.listen(0, "127.0.0.1", resolve))]) + const cpu = { resource: "bridge", id: "*1" }, access = { resource: "ethernet", id: "*2" }, trunk = { resource: "ethernet", id: "*3" } + const desired: BridgeVlanDesired = { + filtering: true, pvid: 1, + ports: [{ id: "*10", interface: access, pvid: 10, ingressFiltering: true, frameTypes: "admit-all", disabled: false }, { interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: "admit-only-vlan-tagged", disabled: false }], + vlans: [{ id: "*11", vlanIds: "10", tagged: [cpu], untagged: [access], disabled: false }, { vlanIds: "20", tagged: [cpu, trunk], untagged: [], disabled: false }], removePortIds: [], removeVlanIds: [], + } + const input = { serverId, bridgeId: "*1", desired } + const issue = async (name: string, serverIds = [serverId], permissions = ["mm:network:write"], operationIds = ["network.bridge-vlan.read", "network.bridge-vlan.configure", "changes.apply", "operations.read", "operations.recover"], mode: "approval" | "autonomous" = "approval") => { + const result = await createMcpConnection(mcpConnectionCreateSchema.parse({ name, mode, expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions, scope: { allowGlobal: false, serverIds, operationIds }, limits: { callsPerMinute: 120, mutationsPerHour: 100 } }), actor) + connections.push(result.connection.id); return { ...result, principal: await authenticateMcpToken(result.token) } + } + const prepare = (principal: Awaited>) => invokeMcpTool(principal, "mm_bridge_vlan_configuration_plan", input) as Promise<{ planId: string; state: string }> + try { + await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,enabled,password) VALUES($1,'bridge-owned','127.0.0.1',$2,false,true,'synthetic-password')", [serverId, (router.address() as { port: number }).port]) + await pool.query("INSERT INTO mcp_management_channels(id,server_id,config_sealed,identity_hash,attestation,created_by,generation,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [channelId, serverId, await encryptSecret(JSON.stringify({ host: "127.0.0.1", port: (management.address() as { port: number }).port, useSsl: false, verifySsl: true, username: "fixture", password: "synthetic-password" })), fingerprint("synthetic-bridge-device"), "Synthetic fixture: no independent network or TLS registration claim", actor.id, await accessGeneration(), new Date(Date.now() + 1800000)]) + const access = await issue("Bridge linked acceptance"), principal = access.principal + const token = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) + const headers = { authorization: "Bearer " + token } + const ownerRead = await app.inject({ method: "GET", url: `/api/mcp/devices/${serverId}/bridge-vlan/configuration?connectionId=${access.connection.id}`, headers }) + assert.equal(ownerRead.statusCode, 200, ownerRead.body) + assert.deepEqual(ownerRead.json(), await invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId })) + const denied = await issue("Bridge excluded server", []), observer = await issue("Bridge read only", [serverId], ["mm:network:read"]) + await assert.rejects(prepare(denied.principal), (error: unknown) => (error as { code?: string }).code === "SERVER_DENIED") + await assert.rejects(prepare(observer.principal), (error: unknown) => (error as { code?: string }).code === "CONNECTION_PERMISSION_MISSING") + assert.deepEqual(rows, initial); assert.equal(commands.length, 0) + const ownerPlan = await app.inject({ method: "POST", url: `/api/mcp/devices/${serverId}/bridge-vlan/configuration/plan`, headers, payload: { connectionId: access.connection.id, bridgeId: input.bridgeId, desired } }) + assert.equal(ownerPlan.statusCode, 200, ownerPlan.body) + const plan = ownerPlan.json() as { planId: string; state: string } + assert.equal(plan.state, "awaiting-approval"); assert.deepEqual(rows, initial); assert.equal(commands.length, 0) + const key = randomUUID() + const apply = (planId: string, idempotencyKey = key) => invokeMcpTool(principal, "mm_change_apply", { planId, idempotencyKey }) as Promise<{ id: string }> + const wait = async (id: string, operationPrincipal = principal) => { + pending.add(id) + for (let n = 0; n < 1200; n++) { + const state = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [id])).rows[0]?.state as string + if (!["pending", "running"].includes(state)) { + pending.delete(id) + return await invokeMcpTool(operationPrincipal, "mm_operation_get", { operationId: id }) as { state: string; verification: string; error_code: string; steps: { state: string }[] } + } + await delay(25) + } + throw Error("Bridge VLAN fixture operation unresolved; do not replay or clean live state") + } + await assert.rejects(apply(plan.planId)); assert.equal(commands.length, 0) + const approval = await app.inject({ method: "POST", url: `/api/mcp/plans/${plan.planId}/decision`, headers, payload: { approve: true } }) + assert.equal(approval.statusCode, 200, approval.body) + const operation = await apply(plan.planId), result = await wait(operation.id) + assert.equal(result.state, "applied", JSON.stringify(result)); assert.equal(result.verification, "passed") + assert.equal(result.steps.length, 3); assert.ok(result.steps.every(step => step.state === "verified")) + assert.deepEqual(commands, ["PUT /interface/bridge/vlan", "PUT /interface/bridge/port", "PATCH /interface/bridge/*1"]) + assert.equal(rows["/interface/bridge"][0]["vlan-filtering"], "true") + for (const path of ["/interface/bridge/port", "/interface/bridge/vlan"]) for (const row of initial[path]) assert.deepEqual(rows[path].find(value => value[".id"] === row[".id"]), row, "Conserve static and foreign configuration") + assert.deepEqual(rows["/interface/bridge"][1], initial["/interface/bridge"][1]); assert.equal(rows["/interface/bridge"][0].opaque, "preserve-bridge") + const readback = await invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId }) as { state: { vlans: Row[]; ports: Row[]; bridges: Row[] } } + assert.equal(readback.state.vlans.length, 3); assert.equal(readback.state.ports.length, 3); assert.equal(readback.state.bridges[0]["vlan-filtering"], "true") + assert.equal((await apply(plan.planId)).id, operation.id); assert.equal(commands.length, 3) + const recover = (operationId: string, sourcePrincipal = principal) => invokeMcpTool(sourcePrincipal, "mm_operation_recovery_plan", { operationId }) as Promise<{ planId: string; state: string }> + const completedForward = structuredClone(rows) + rows["/interface/bridge/vlan"][1].comment = "external-recovery-drift" + await assert.rejects(recover(operation.id), (error: unknown) => (error as { code?: string }).code === "STATE_CONFLICT") + assert.equal(commands.length, 3); rows = completedForward + const createReceipt = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=0", [operation.id])).rows[0].result + await pool.query("UPDATE mcp_operation_steps SET result=$2::jsonb WHERE operation_id=$1 AND step_index=0", [operation.id, JSON.stringify({ ...createReceipt, ".id": "*beef" })]) + try { await assert.rejects(recover(operation.id), (error: unknown) => (error as { code?: string }).code === "STATE_CONFLICT") } + finally { await pool.query("UPDATE mcp_operation_steps SET result=$2::jsonb WHERE operation_id=$1 AND step_index=0", [operation.id, JSON.stringify(createReceipt)]) } + assert.equal(commands.length, 3, "A checkpoint delta cannot replace the actual create ACK") + const savedEvidence = (await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [plan.planId])).rows[0].evidence + const obsolete = JSON.parse(await decryptSecret(savedEvidence.sealed)) + obsolete.snapshots.find((snapshot: { resource: string }) => snapshot.resource === "bridge_port").projectionVersion = 3 + await pool.query("UPDATE mcp_plans SET evidence=$2::jsonb WHERE id=$1", [plan.planId, JSON.stringify({ sealed: await encryptSecret(JSON.stringify(obsolete)) })]) + try { await assert.rejects(recover(operation.id), (error: unknown) => (error as { code?: string }).code === "STATE_CONFLICT") } + finally { await pool.query("UPDATE mcp_plans SET evidence=$2::jsonb WHERE id=$1", [plan.planId, JSON.stringify(savedEvidence)]) } + const newChannelId = randomUUID() + await pool.query("UPDATE mcp_management_channels SET id=$2 WHERE id=$1", [channelId, newChannelId]) + const inverse = await recover(operation.id) + assert.equal(inverse.state, "awaiting-approval"); assert.equal(commands.length, 3) + const inverseEvidence = JSON.parse(await decryptSecret((await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [inverse.planId])).rows[0].evidence.sealed)) + assert.equal(inverseEvidence.managementChannels[0].channelId, newChannelId, "Inverse freshly verifies current management, never copies the expired source receipt") + await assert.rejects(apply(inverse.planId, randomUUID())); assert.equal(commands.length, 3) + await approvePlan(inverse.planId, actor, true) + const inverseOperation = await apply(inverse.planId, randomUUID()), inverseResult = await wait(inverseOperation.id) + assert.equal(inverseResult.state, "applied", JSON.stringify(inverseResult)); assert.equal(inverseResult.verification, "passed") + assert.ok(inverseResult.steps.every(step => step.state === "verified")) + assert.deepEqual(commands.slice(3).map(command => command.replace(/\*[^/]+$/, "*id")), ["PATCH /interface/bridge/*id", "DELETE /interface/bridge/vlan/*id", "DELETE /interface/bridge/port/*id"]) + assert.deepEqual(rows, initial, "Approved typed recovery restores the exact baseline and preserves foreign rows") + rows = structuredClone(initial); commands = [] + // Legacy tools get their original operation grants only. They cannot gain + // linked configuration authority or emit extra native resource changes. + const generic = await issue("Bridge legacy safety", [serverId], ["mm:network:write"], ["ros.bridge.update", "ros.bridge_port.update", "ros.bridge_vlan.update", "interfaces.update", "changes.apply", "operations.read", "operations.recover"]) + const genericPlan = (tool: string, body: Record) => invokeMcpTool(generic.principal, tool, { serverId, ...body }) as Promise<{ planId: string; state: string }> + const plansBefore = (await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n + rows["/interface/bridge/vlan"][0].untagged = "" + rows["/interface/bridge/vlan"][0].tagged = "br-users,ether2" + const invalidFiltering = structuredClone(rows) + for (const [tool, body] of [ + ["mm_bridge_update_plan", { id: "*1", fields: { "vlan-filtering": true } }], + ["mm_interfaces_update_plan", { resource: "bridge", id: "*1", fields: { "vlan-filtering": true } }], + ] as const) { + await assert.rejects(genericPlan(tool, body), (error: unknown) => (error as { code?: string }).code === "INVALID_INPUT") + assert.deepEqual(rows, invalidFiltering); assert.equal(commands.length, 0) + } + rows = structuredClone(initial) + await assert.rejects(genericPlan("mm_bridge_vlan_update_plan", { id: "*11", fields: { bridge: "br-foreign" } }), (error: unknown) => (error as { code?: string }).code === "STATE_CONFLICT") + await assert.rejects(genericPlan("mm_bridge_vlan_update_plan", { id: "*11", fields: { tagged: "br-users,ether2" } }), (error: unknown) => (error as { code?: string }).code === "INVALID_INPUT") + assert.deepEqual(rows, initial); assert.equal(commands.length, 0) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n, plansBefore, "Invalid legacy requests produce neither plans nor writes") + let raced = false + beforePrimaryIdentity = () => { raced = true; rows["/interface/bridge/port"][0].pvid = "11" } + await assert.rejects(genericPlan("mm_bridge_port_update_plan", { id: "*10", fields: { "ingress-filtering": false } }), (error: unknown) => { + const value = error as { code?: string; message?: string } + return value.code === "STATE_CONFLICT" && !!value.message?.includes("незапрошенные поля") + }) + assert.equal(raced, true); assert.equal(commands.length, 0) + assert.equal(rows["/interface/bridge/port"][0].pvid, "11", "Concurrent PVID is not silently reverted by a different requested field") + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n, plansBefore) + rows = structuredClone(initial) + const single = await genericPlan("mm_bridge_port_update_plan", { id: "*10", fields: { "ingress-filtering": false } }) + assert.equal(single.state, "awaiting-approval"); assert.deepEqual(rows, initial) + await approvePlan(single.planId, actor, true) + const singleOperation = await invokeMcpTool(generic.principal, "mm_change_apply", { planId: single.planId, idempotencyKey: randomUUID() }) as { id: string } + const singleResult = await wait(singleOperation.id, generic.principal) + assert.equal(singleResult.state, "applied", JSON.stringify(singleResult)); assert.equal(singleResult.verification, "passed") + assert.deepEqual(singleResult.steps.map(step => step.state), ["verified"]) + assert.deepEqual(commands, ["PATCH /interface/bridge/port/*10"]) + const expected = structuredClone(initial); expected["/interface/bridge/port"][0]["ingress-filtering"] = "false" + assert.deepEqual(rows, expected, "Valid single-field legacy change emits exactly its original command") + const singleInverse = await recover(singleOperation.id, generic.principal) + await approvePlan(singleInverse.planId, actor, true) + const singleRecovered = await invokeMcpTool(generic.principal, "mm_change_apply", { planId: singleInverse.planId, idempotencyKey: randomUUID() }) as { id: string } + const singleRecoveryResult = await wait(singleRecovered.id, generic.principal) + assert.equal(singleRecoveryResult.state, "applied", JSON.stringify(singleRecoveryResult)); assert.equal(singleRecoveryResult.verification, "passed") + assert.deepEqual(rows, initial, "Sensitive generic source uses typed inverse without linked configure grant") + rows = structuredClone(initial); commands = [] + const deletionForward = await prepare(principal); await approvePlan(deletionForward.planId, actor, true) + assert.equal((await wait((await apply(deletionForward.planId, randomUUID())).id)).state, "applied") + const beforeDeletion = (await invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId }) as { state: BridgeVlanState }).state + const removedPortId = String(beforeDeletion.ports.find(row => row.interface === "ether3")![".id"]) + const removedVlanId = String(beforeDeletion.vlans.find(row => row["vlan-ids"] === "20")![".id"]) + const deletionDesired = bridgeVlanCurrentDesired(beforeDeletion, "*1") + deletionDesired.ports = deletionDesired.ports.filter(row => row.id !== removedPortId); deletionDesired.removePortIds = [removedPortId] + deletionDesired.vlans = deletionDesired.vlans.filter(row => row.id !== removedVlanId); deletionDesired.removeVlanIds = [removedVlanId] + const deletion = await invokeMcpTool(principal, "mm_bridge_vlan_configuration_plan", { serverId, bridgeId: "*1", desired: deletionDesired }) as { planId: string } + await approvePlan(deletion.planId, actor, true) + const deletedOperation = await apply(deletion.planId, randomUUID()), deletedResult = await wait(deletedOperation.id) + assert.equal(deletedResult.state, "applied", JSON.stringify(deletedResult)); assert.ok(deletedResult.steps.every(step => step.state === "verified")) + const recreate = await recover(deletedOperation.id); assert.equal(recreate.state, "awaiting-approval") + await approvePlan(recreate.planId, actor, true) + const recreated = await wait((await apply(recreate.planId, randomUUID())).id) + assert.equal(recreated.state, "applied", JSON.stringify(recreated)); assert.equal(recreated.verification, "passed"); assert.ok(recreated.steps.every(step => step.state === "verified")) + const afterRecreation = (await invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId }) as { state: BridgeVlanState }).state + assert.notEqual(afterRecreation.ports.find(row => row.interface === "ether3")![".id"], removedPortId) + assert.notEqual(afterRecreation.vlans.find(row => row["vlan-ids"] === "20")![".id"], removedVlanId) + const topology = (state: BridgeVlanState) => ({ ...state, + ports: state.ports.map(({ ".id": _id, ...row }) => row).sort((a, b) => String(a.bridge + ":" + a.interface).localeCompare(String(b.bridge + ":" + b.interface))), + vlans: state.vlans.map(({ ".id": _id, ...row }) => row).sort((a, b) => String(a.bridge + ":" + a["vlan-ids"]).localeCompare(String(b.bridge + ":" + b["vlan-ids"]))), + }) + assert.deepEqual(topology(afterRecreation), topology(beforeDeletion), "Deleted memberships and ports restore configuration with newly acknowledged IDs") + for (const path of ["/interface/bridge/port", "/interface/bridge/vlan"]) for (const row of initial[path]) assert.deepEqual(rows[path].find(value => value[".id"] === row[".id"]), row) + rows = structuredClone(initial); commands = [] + const stale = await prepare(principal); await approvePlan(stale.planId, actor, true) + rows["/interface/bridge/vlan"][1].comment = "external-drift" + const drift = await wait((await apply(stale.planId, randomUUID())).id) + assert.equal(drift.state, "rejected", JSON.stringify(drift)); assert.equal(commands.length, 0, "Drift prevents every native write") + rows = structuredClone(initial); commands = [] + const lost = await prepare(principal); await approvePlan(lost.planId, actor, true); lostAck = true + const lostKey = randomUUID(), uncertain = await apply(lost.planId, lostKey), unknown = await wait(uncertain.id) + assert.equal(unknown.state, "unknown", JSON.stringify(unknown)); assert.equal(commands.length, 1) + assert.equal(rows["/interface/bridge/port"].length, initial["/interface/bridge/port"].length) + assert.equal(rows["/interface/bridge"][0]["vlan-filtering"], "false") + assert.equal((await apply(lost.planId, lostKey)).id, uncertain.id); assert.equal(commands.length, 1, "Unknown ACK never retries or continues") + await assert.rejects(recover(uncertain.id), (error: unknown) => (error as { code?: string }).code === "STATE_CONFLICT") + assert.equal(commands.length, 1, "Unknown source has no inverse authority") + assert.ok(managementReads > 1 && primaryIdentityReads > 1, "Fresh same-identity verification on both synthetic endpoints") + rows = structuredClone(initial); commands = [] + await pool.query("UPDATE mcp_management_channels SET identity_hash=$2 WHERE server_id=$1", [serverId, fingerprint("wrong-device")]) + await assert.rejects(prepare(principal), (error: unknown) => (error as { code?: string }).code === "STATE_CONFLICT") + assert.equal(commands.length, 0, "A registered channel identity mismatch cannot fall back to primary") + await pool.query("DELETE FROM mcp_management_channels WHERE server_id=$1", [serverId]) + rows["/ip/address"][0].address = "192.0.2.1/24" + await assert.rejects(prepare(principal), (error: unknown) => (error as { code?: string }).code === "MANAGEMENT_UNSAFE") + rows = structuredClone(initial) + const stolenManagement = structuredClone(desired) + stolenManagement.ports.push({ interface: { resource: "ethernet", id: "*4" }, pvid: 1, ingressFiltering: true, frameTypes: "admit-only-vlan-tagged", disabled: false }) + stolenManagement.vlans.push({ vlanIds: "30", tagged: [cpu, { resource: "ethernet", id: "*4" }], untagged: [], disabled: false }) + await assert.rejects(invokeMcpTool(principal, "mm_bridge_vlan_configuration_plan", { ...input, desired: stolenManagement }), (error: unknown) => (error as { code?: string }).code === "MANAGEMENT_UNSAFE") + rows["/ip/address"][0].interface = "br-users" + await assert.rejects(prepare(principal), (error: unknown) => (error as { code?: string }).code === "MANAGEMENT_UNSAFE") + assert.equal(commands.length, 0, "Primary admission refuses unconfirmed address, slave stealing and CPU delivery loss") + rows = structuredClone(initial) + const primaryPlan = await prepare(principal) + assert.equal(primaryPlan.state, "awaiting-approval"); assert.equal(commands.length, 0) + assert.equal((primaryPlan as unknown as { changes: { managementChannels?: unknown } }).changes.managementChannels, undefined, "Public primary plan never exposes a fake independent management receipt") + const primaryEvidence = JSON.parse(await decryptSecret((await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [primaryPlan.planId])).rows[0].evidence.sealed)) + assert.equal(primaryEvidence.requiresHuman, true) + assert.equal(primaryEvidence.managementChannels, undefined, "Primary management is never represented as a registered independent receipt") + assert.ok(primaryEvidence.warnings.some((warning: string) => warning.includes("основной адрес") && warning.includes("независимость канала не подтверждена"))) + assert.ok(Date.parse(primaryEvidence.validUntil) <= Date.parse(principal.credentialExpiresAt) && Date.parse(primaryEvidence.validUntil) <= actor.expiresAt!) + await assert.rejects(apply(primaryPlan.planId, randomUUID())); assert.equal(commands.length, 0) + await approvePlan(primaryPlan.planId, actor, true) + const primaryOperation = await apply(primaryPlan.planId, randomUUID()), primaryResult = await wait(primaryOperation.id) + assert.equal(primaryResult.state, "applied", JSON.stringify(primaryResult)); assert.equal(primaryResult.verification, "passed"); assert.ok(primaryResult.steps.every(step => step.state === "verified")) + const primaryInverse = await recover(primaryOperation.id) + const primaryInverseEvidence = JSON.parse(await decryptSecret((await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [primaryInverse.planId])).rows[0].evidence.sealed)) + assert.equal(primaryInverseEvidence.managementChannels, undefined) + await approvePlan(primaryInverse.planId, actor, true) + const primaryRecovered = await wait((await apply(primaryInverse.planId, randomUUID())).id) + assert.equal(primaryRecovered.state, "applied", JSON.stringify(primaryRecovered)); assert.equal(primaryRecovered.verification, "passed") + assert.deepEqual(rows, initial) + // A normal filtering-enabled CPU/access management VLAN may consist only + // of native dynamic PVID membership, without any static VLAN table row. + rows["/interface/bridge"][0]["vlan-filtering"] = "true"; rows["/interface/bridge"][0].pvid = "10" + rows["/ip/address"][0].interface = "br-users" + rows["/interface/bridge/vlan"] = rows["/interface/bridge/vlan"].filter(row => row[".id"] !== "*11") + rows["/interface/bridge/vlan"].find(row => row.dynamic === "true")!["current-untagged"] = "br-users,ether2" + const implicitBaseline = structuredClone(rows) + const implicitState = (await invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId }) as { state: BridgeVlanState }).state + const implicitDesired = bridgeVlanCurrentDesired(implicitState, "*1") + assert.deepEqual(implicitDesired.vlans, []) + const implicitPrepare = (next: BridgeVlanDesired) => invokeMcpTool(principal, "mm_bridge_vlan_configuration_plan", { serverId, bridgeId: "*1", desired: next }) as Promise<{ planId: string; state: string }> + for (const mutate of [ + (next: BridgeVlanDesired) => { next.pvid = 11 }, + (next: BridgeVlanDesired) => { next.ports[0].pvid = 11 }, + (next: BridgeVlanDesired) => { next.frameTypes = "admit-only-vlan-tagged" }, + ]) { + const unsafe = structuredClone(implicitDesired); mutate(unsafe) + await assert.rejects(implicitPrepare(unsafe), (error: unknown) => (error as { code?: string }).code === "MANAGEMENT_UNSAFE") + } + for (const [tool, body] of [ + ["mm_bridge_update_plan", { id: "*1", fields: { pvid: 11 } }], + ["mm_bridge_port_update_plan", { id: "*10", fields: { pvid: 11 } }], + ["mm_interfaces_update_plan", { resource: "bridge", id: "*1", fields: { "frame-types": "admit-only-vlan-tagged" } }], + ] as const) await assert.rejects(genericPlan(tool, body), (error: unknown) => (error as { code?: string }).code === "MANAGEMENT_UNSAFE") + const writesBeforeImplicit = commands.length + rows["/interface/bridge"][0].mvrp = "true" + await assert.rejects(implicitPrepare(implicitDesired), (error: unknown) => (error as { code?: string }).code === "UNSUPPORTED_CAPABILITY") + rows = structuredClone(implicitBaseline) + for (const field of ["frame-types", "ingress-filtering"]) { + delete rows["/interface/bridge"][0][field] + await assert.rejects(implicitPrepare(implicitDesired)) + rows = structuredClone(implicitBaseline) + } + assert.equal(commands.length, writesBeforeImplicit) + implicitDesired.ports[0].ingressFiltering = false + const implicitPlan = await implicitPrepare(implicitDesired) + assert.equal(implicitPlan.state, "awaiting-approval"); assert.equal(commands.length, writesBeforeImplicit) + await approvePlan(implicitPlan.planId, actor, true) + const implicitOperation = await apply(implicitPlan.planId, randomUUID()), implicitResult = await wait(implicitOperation.id) + assert.equal(implicitResult.state, "applied", JSON.stringify(implicitResult)); assert.equal(implicitResult.verification, "passed") + assert.deepEqual(implicitResult.steps.map(step => step.state), ["verified"]) + assert.deepEqual(commands.slice(writesBeforeImplicit), ["PATCH /interface/bridge/port/*10"], "Derived dynamic PVID rows never become native commands") + const implicitInverse = await recover(implicitOperation.id) + await approvePlan(implicitInverse.planId, actor, true) + const implicitRecovered = await wait((await apply(implicitInverse.planId, randomUUID())).id) + assert.equal(implicitRecovered.state, "applied", JSON.stringify(implicitRecovered)); assert.equal(implicitRecovered.verification, "passed") + assert.deepEqual(rows, implicitBaseline, "Implicit-PVID CPU management survives approved forward and typed inverse") + const beforeCpuEdit = commands.length + const cpuOnly = await genericPlan("mm_interfaces_update_plan", { resource: "bridge", id: "*1", fields: { "ingress-filtering": false } }) + await approvePlan(cpuOnly.planId, actor, true) + const cpuOperation = await invokeMcpTool(generic.principal, "mm_change_apply", { planId: cpuOnly.planId, idempotencyKey: randomUUID() }) as { id: string } + const cpuResult = await wait(cpuOperation.id, generic.principal) + assert.equal(cpuResult.state, "applied", JSON.stringify(cpuResult)); assert.equal(cpuResult.verification, "passed") + assert.deepEqual(commands.slice(beforeCpuEdit), ["PATCH /interface/bridge/*1"]) + const cpuInverse = await recover(cpuOperation.id, generic.principal); await approvePlan(cpuInverse.planId, actor, true) + const cpuRecovered = await invokeMcpTool(generic.principal, "mm_change_apply", { planId: cpuInverse.planId, idempotencyKey: randomUUID() }) as { id: string } + assert.equal((await wait(cpuRecovered.id, generic.principal)).state, "applied") + assert.deepEqual(rows, implicitBaseline, "CPU ingress alias edit and typed inverse preserve implicit PVID management without extra authority") + rows = structuredClone(initial) + const autonomous = await issue("Primary still human approved", [serverId], ["mm:network:write"], undefined, "autonomous") + const humanOnly = await prepare(autonomous.principal) + assert.equal(humanOnly.state, "awaiting-approval") + const beforeAutonomousApply = commands.length + await assert.rejects(invokeMcpTool(autonomous.principal, "mm_change_apply", { planId: humanOnly.planId, idempotencyKey: randomUUID() }), (error: unknown) => (error as { code?: string }).code === "APPROVAL_REQUIRED") + assert.equal(commands.length, beforeAutonomousApply); assert.deepEqual(rows, initial) + console.log("✓ Linked bridge VLAN SQL/HTTP: approved forward/recovery and delete recreation, fresh registered or human-only primary management, unsafe primary/identity and autonomous bypass refusals, terminal drift/ACK/projection guards, REST/MCP and legacy safety; HTTP fixture only, no TLS/independence claim") + } finally { + if (!pending.size) { + for (const id of connections) await setConnectionStatus(id, "paused", actor) + await pool.query("DELETE FROM servers WHERE id=$1", [serverId]) + } + await Promise.all([new Promise((resolve, reject) => router.close(error => error ? reject(error) : resolve())), new Promise((resolve, reject) => management.close(error => error ? reject(error) : resolve()))]) + } +} diff --git a/backend/src/test/mcp-users-bindings.ts b/backend/src/test/mcp-users-bindings.ts new file mode 100644 index 00000000..454e2c5d --- /dev/null +++ b/backend/src/test/mcp-users-bindings.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" +import { createServer } from "node:http" +import { setTimeout as delay } from "node:timers/promises" +import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp" +import type { AuthUser } from "../lib/permissions.js" +import { pool } from "../db/index.js" +import { ensurePartitionFor } from "../db/partitions.js" +import { createMcpConnection, authenticateMcpToken, setConnectionStatus } from "../modules/mcp/store.js" +import { invokeMcpTool, approvePlan } from "../modules/mcp/runtime.js" +import { addBinding } from "../modules/users/service/users-service.js" +import { userBindingCreateSchema } from "@mmapp/contracts/users" + +/** Owned PostgreSQL and loopback RouterOS fixture; never writes to a real router. */ +export async function verifyMcpUserBindings(actor: AuthUser) { + const serverId = 91731, login = "mcp-binding-" + randomUUID(), connections: string[] = [] + let userId: string | undefined, otherUserId: string | undefined, nativeReads = 0, nativeWrites = 0 + const otherLogin = login + "-other", wgKey = Buffer.alloc(32, 7).toString("base64") + const fixtures = [ + { name: "ether-owned-user", type: "ether", raw: "ether" }, + { name: "bridge-owned-user", type: "bridge", raw: "bridge" }, + { name: "vlan-owned-user", type: "vlan", raw: "vlan" }, + { name: "vxlan-owned-user", type: "vxlan", raw: "vxlan" }, + { name: "gre-owned-user", type: "gre", raw: "gre-tunnel" }, + { name: "other-owned-user", type: "other", raw: "dummy" }, + { name: "wg-owned-user", type: "wg", raw: "wg", peerPublicKey: wgKey }, + { name: "ipsec-vpn", type: "ipsec", raw: "ipsec", peerPublicKey: "owned-client-cn" }, + { name: "ipsec-vpn", type: "ipsec", raw: "ipsec", peerPublicKey: "owned-psk" }, + ] + const pendingOperations = new Set() + const router = createServer((req, res) => { + res.setHeader("content-type", "application/json") + if (req.method !== "GET") { nativeWrites++; res.statusCode = 405; res.end("{}"); return } + nativeReads++ + const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname.replace(/^\/rest/, "") + const rows = path === "/interface" ? fixtures.filter(iface => iface.type !== "ipsec").map((iface, index) => ({ ".id": "*" + (index + 1).toString(16), name: iface.name, type: iface.raw, running: "true", disabled: "false" })) + : path === "/interface/wireguard/peers" + ? [{ ".id": "*A", interface: "wg-owned-user", "public-key": wgKey, name: "Owned WG peer", "allowed-address": "192.0.2.7/32", disabled: "false" }] + : path === "/certificate" ? [{ ".id": "*B", name: "ipsec-user-owned-client", "common-name": "owned-client-cn", ca: "owned-ca", "key-usage": "tls-client" }] + : path === "/ip/ipsec/identity" ? [{ ".id": "*C", peer: "ike-owned", "auth-method": "pre-shared-key", comment: "MikrotikManager:ipsec user=owned-psk" }] : [] + res.end(JSON.stringify(rows)) + }) + await new Promise(resolve => router.listen(0, "127.0.0.1", resolve)) + const operations = ["users.create", "users.get", "users.interfaces.read", "users.interfaces.state", "users.binding_add", "users.binding_remove", "users.delete", "changes.apply", "operations.read"] + const issue = async (name: string, serverIds: number[], permissions = ["mm:users:write"]) => { + const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions, + scope: { allowGlobal: true, serverIds, operationIds: operations }, limits: { callsPerMinute: 2000, mutationsPerHour: 100 } }), actor) + connections.push(access.connection.id) + return authenticateMcpToken(access.token) + } + const state = async () => ({ + users: (await pool.query("SELECT * FROM app_users WHERE login=ANY($1::text[]) ORDER BY login", [[login, otherLogin]])).rows, + bindings: (await pool.query("SELECT * FROM user_interface_bindings WHERE server_id=$1 ORDER BY id", [serverId])).rows, + }) + const matches = (code: string) => (error: unknown) => (error as { code?: string }).code === code + try { + await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,enabled) VALUES($1,'owned-user-binding','127.0.0.1',$2,false,true)", [serverId, (router.address() as { port: number }).port]) + const sampledAt = new Date() + await ensurePartitionFor(pool, "server_snapshots", "week", sampledAt) + await pool.query("INSERT INTO server_snapshots(server_id,polled_at,status,raw_interfaces) VALUES($1,$2,'online',$3::jsonb)", [serverId, sampledAt.toISOString(), JSON.stringify(fixtures.filter(iface => iface.type !== "ipsec").map(iface => ({ name: iface.name, type: iface.raw, running: true, disabled: false })))]) + const principal = await issue("User binding journey", [serverId]) + const narrow = await issue("User binding excluded server", []) + const readOnly = await issue("User binding read only", [serverId], ["mm:users:read"]) + const getUser = async () => (await invokeMcpTool(principal, "mm_users_get", { id: userId }) as { user: { id: string; login: string; bindings: { id: string; serverId: number; interfaceName: string; interfaceType: string; peerPublicKey: string }[] } }).user + const apply = async (tool: string, input: unknown, expected: "applied" | "stale" | "race" = "applied", prepared?: { planId: string; state: string }) => { + const before = await state() + const plan = prepared ?? await invokeMcpTool(principal, tool, input) as { planId: string; state: string } + assert.equal(plan.state, "awaiting-approval") + assert.deepEqual(await state(), before, tool + " preparation changes no users or bindings") + const key = randomUUID() + await assert.rejects(invokeMcpTool(principal, "mm_change_apply", { planId: plan.planId, idempotencyKey: key }), matches("APPROVAL_REQUIRED")) + assert.deepEqual(await state(), before, tool + " unapproved apply changes no users or bindings") + await approvePlan(plan.planId, actor, true) + const operation = await invokeMcpTool(principal, "mm_change_apply", { planId: plan.planId, idempotencyKey: key }) as { id: string } + pendingOperations.add(operation.id) + for (let attempt = 0; attempt < 600; attempt++) { + const result = await invokeMcpTool(principal, "mm_operation_get", { operationId: operation.id }) as { state: string; verification: string; steps: { state: string }[] } + if (!["pending", "running"].includes(result.state)) { + pendingOperations.delete(operation.id) + if (expected === "applied") { + assert.equal(result.state, "applied", JSON.stringify(result)) + assert.equal(result.verification, "passed") + assert.equal(result.steps.length, 1) + assert.ok(result.steps.every(step => step.state === "verified")) + } else { + assert.equal(result.state, expected === "stale" ? "rejected" : "unknown", JSON.stringify(result)) + assert.deepEqual(await state(), before, "Refused race/stale plan leaves SQL ownership unchanged") + const stopped = (await pool.query("SELECT error_code,result FROM mcp_operations WHERE id=$1", [operation.id])).rows[0] + assert.equal(stopped.result.confirmedWrites, 0) + assert.equal(stopped.error_code, expected === "stale" ? "STATE_CONFLICT" : "DEVICE_UNAVAILABLE") + } + assert.equal((await invokeMcpTool(principal, "mm_change_apply", { planId: plan.planId, idempotencyKey: key }) as { id: string }).id, operation.id) + return + } + await delay(25) + } + throw Error("User binding operation remains live; never replay") + } + await apply("mm_users_create_plan", { user: { login, name: "Owned user binding", email: "binding@example.invalid" } }) + userId = (await state()).users[0]?.id + assert.ok(userId) + assert.equal((await getUser()).login, login) + assert.deepEqual((await getUser()).bindings, []) + await apply("mm_users_create_plan", { user: { login: otherLogin, name: "Other owned user", email: "other@example.invalid" } }) + otherUserId = (await state()).users.find(row => row.login === otherLogin)?.id + assert.ok(otherUserId) + const invalidSelectors = [ + { interfaceName: "missing-native-interface" }, + { interfaceName: "wg-owned-user", interfaceType: "ether" }, + { interfaceName: "wg-owned-user", peerPublicKey: Buffer.alloc(32, 9).toString("base64") }, + { interfaceName: "ipsec-vpn", interfaceType: "ipsec", peerPublicKey: "missing-native-cn" }, + ] + for (const [index, binding] of invalidSelectors.entries()) { + const before = await state(), plansBefore = (await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n + await assert.rejects(invokeMcpTool(principal, "mm_users_binding_add_plan", { id: userId, binding: { serverId, ...binding } }), matches(index === 1 ? "STATE_CONFLICT" : "NOT_FOUND")) + assert.deepEqual(await state(), before) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n, plansBefore) + } + type CatalogEntry = { name: string; type: string; boundUserId: string | null; boundUserLogin: string | null; peers?: { publicKey: string; boundUserId: string | null; boundUserLogin: string | null }[] } + const catalog = async () => (await invokeMcpTool(principal, "mm_user_interfaces_list", { serverId }) as { interfaces: CatalogEntry[] }).interfaces + const owner = async (fixture: typeof fixtures[number]) => { + const iface = (await catalog()).find(entry => entry.name === fixture.name) + assert.ok(iface, fixture.name + " appears in catalog") + assert.equal(iface.type, fixture.type) + const entry = fixture.peerPublicKey ? iface.peers?.find(peer => peer.publicKey === fixture.peerPublicKey) : iface + assert.ok(entry, fixture.name + " peer appears in catalog") + return { id: entry.boundUserId, login: entry.boundUserLogin } + } + const catalogState = await invokeMcpTool(principal, "mm_user_interfaces_state_get", { serverId }) as { source: string; inventoryComplete: boolean; failures: unknown[] } + assert.equal(catalogState.source, "snapshot") + assert.equal(catalogState.inventoryComplete, true) + assert.deepEqual(catalogState.failures, []) + for (const tool of ["mm_user_interfaces_list", "mm_user_interfaces_state_get"]) { + await assert.rejects(invokeMcpTool(narrow, tool, { serverId }), matches("SERVER_DENIED")) + } + for (const fixture of fixtures) { + // Infer ordinary/WG types from native rows; verify the service's canonical + // name/key, including ordinary-interface peer clearing, after real INSERT. + const binding = { serverId, interfaceName: ` ${fixture.name} `, peerPublicKey: fixture.peerPublicKey ? ` ${fixture.peerPublicKey} ` : " ignored-non-peer ", ...(fixture.type === "ipsec" ? { interfaceType: "ipsec" } : {}) } + assert.deepEqual(await owner(fixture), { id: null, login: null }) + const beforeDenied = await state() + const plansBefore = (await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n + await assert.rejects(invokeMcpTool(narrow, "mm_users_binding_add_plan", { id: userId, binding }), matches("SERVER_DENIED")) + await assert.rejects(invokeMcpTool(readOnly, "mm_users_binding_add_plan", { id: userId, binding }), matches("CONNECTION_PERMISSION_MISSING")) + assert.deepEqual(await state(), beforeDenied) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans")).rows[0].n, plansBefore, "Denied requests create no plans") + // Preparation cannot reserve the tuple: another owner can bind it before apply. + const racedInput = { id: otherUserId, binding } + const racedPlan = await invokeMcpTool(principal, "mm_users_binding_add_plan", racedInput) as { planId: string; state: string } + await apply("mm_users_binding_add_plan", { id: userId, binding }) + const bound = (await getUser()).bindings + assert.equal(bound.length, 1) + assert.equal(bound[0].serverId, serverId) + assert.equal(bound[0].interfaceName, fixture.name) + assert.equal(bound[0].interfaceType, fixture.type) + assert.equal(bound[0].peerPublicKey, fixture.peerPublicKey ?? "") + assert.deepEqual(await owner(fixture), { id: userId, login }) + const hidden: { incomplete: boolean; bindings: unknown[] } = (await invokeMcpTool(narrow, "mm_users_get", { id: userId }) as { user: { incomplete: boolean; bindings: unknown[] } }).user + assert.equal(hidden.incomplete, true) + assert.deepEqual(hidden.bindings, []) + const beforeConflict = await state() + const counts = async () => (await pool.query("SELECT (SELECT count(*)::int FROM mcp_plans) AS plans,(SELECT count(*)::int FROM mcp_operations) AS operations")).rows[0] + const beforeCounts = await counts() + if (fixture === fixtures[0]) { + const readsBefore = nativeReads + await assert.rejects(invokeMcpTool(principal, "mm_users_binding_remove_plan", { id: userId, bindingId: randomUUID() }), matches("NOT_FOUND")) + await assert.rejects(invokeMcpTool(principal, "mm_users_binding_remove_plan", { id: otherUserId, bindingId: bound[0].id }), matches("NOT_FOUND")) + assert.equal(nativeReads, readsBefore, "Invalid removal is refused from the current user snapshot") + assert.deepEqual(await counts(), beforeCounts, "Invalid removal creates no plan or operation") + assert.deepEqual(await state(), beforeConflict) + } + for (const id of [userId, otherUserId]) { + await assert.rejects(invokeMcpTool(principal, "mm_users_binding_add_plan", { id, binding }), matches("STATE_CONFLICT")) + } + await assert.rejects(invokeMcpTool(narrow, "mm_users_binding_add_plan", { id: userId, binding }), matches("SERVER_DENIED")) + await assert.rejects(invokeMcpTool(readOnly, "mm_users_binding_add_plan", { id: userId, binding }), matches("CONNECTION_PERMISSION_MISSING")) + if (fixture.peerPublicKey) { + await assert.rejects(invokeMcpTool(principal, "mm_users_binding_add_plan", { id: userId, binding: { ...binding, peerPublicKey: "" } }), matches("INVALID_INPUT")) + } + assert.deepEqual(await counts(), beforeCounts, "Deterministic duplicate/takeover creates no plan or operation") + assert.deepEqual(await state(), beforeConflict) + // REST's shared SQL service retains its 409 contract. + for (const id of [userId, otherUserId]) { + await assert.rejects(addBinding(id, userBindingCreateSchema.parse(binding)), (error: unknown) => (error as { status?: number }).status === 409) + } + assert.deepEqual(await state(), beforeConflict) + await apply("mm_users_binding_add_plan", racedInput, "race", racedPlan) + assert.deepEqual(await owner(fixture), { id: userId, login }) + const beforeRemoveDenied = await state() + for (const tool of ["mm_users_binding_remove_plan", "mm_users_delete_plan"]) { + await assert.rejects(invokeMcpTool(narrow, tool, tool.includes("binding") ? { id: userId, bindingId: bound[0].id } : { id: userId }), matches("SERVER_DENIED")) + } + assert.deepEqual(await state(), beforeRemoveDenied) + const removeInput = { id: userId, bindingId: bound[0].id } + const staleRemoval = await invokeMcpTool(principal, "mm_users_binding_remove_plan", removeInput) as { planId: string; state: string } + await apply("mm_users_binding_remove_plan", removeInput) + assert.deepEqual((await getUser()).bindings, []) + assert.deepEqual(await owner(fixture), { id: null, login: null }) + await apply("mm_users_binding_remove_plan", removeInput, "stale", staleRemoval) + assert.deepEqual(await owner(fixture), { id: null, login: null }) + } + await apply("mm_users_delete_plan", { id: otherUserId }) + await apply("mm_users_delete_plan", { id: userId }) + await assert.rejects(invokeMcpTool(principal, "mm_users_get", { id: userId }), matches("NOT_FOUND")) + assert.deepEqual(await state(), { users: [], bindings: [] }) + assert.ok(nativeReads > 0, "Catalog consulted loopback RouterOS fixture") + assert.equal(nativeWrites, 0, "User binding workflow never mutates RouterOS") + console.log("✓ MCP users: eight native binding types plus IPsec PSK, missing/type-spoof/peer/CN refusal, add/read/remove, catalog owner, duplicate/takeover refusal, approval/replay and scope; owned PostgreSQL/RouterOS stub") + } finally { + // Never race cleanup against an unresolved operation. This helper runs only + // inside the disposable owned profile, whose runner tears down its database. + if (pendingOperations.size === 0) { + for (const id of connections) await setConnectionStatus(id, "paused", actor) + await pool.query("DELETE FROM app_users WHERE login=ANY($1::text[])", [[login, otherLogin]]) + await pool.query("DELETE FROM servers WHERE id=$1", [serverId]) + } + await new Promise((resolve, reject) => router.close(error => error ? reject(error) : resolve())) + } +} diff --git a/backend/src/test/vrf-production-execution-owned.ts b/backend/src/test/vrf-production-execution-owned.ts index 2d9ab3ea..13f61d46 100644 --- a/backend/src/test/vrf-production-execution-owned.ts +++ b/backend/src/test/vrf-production-execution-owned.ts @@ -16,7 +16,7 @@ if (process.env.MMAPP_VRF_SHARED_OWNED_PG === "true") { } else assert.equal(target.pathname, "/postgres") assert(process.env.MCP_STATE_DIR && process.env.MMAPP_VRF_NATIVE_REPORT) const slice = process.env.MMAPP_VRF_EXECUTION_SLICE ?? "baseline" -assert(["baseline", "physical", "vlan", "disabled", "mixed", "reconcile", "sql-journal", "guards", "coexistence", "alias"].includes(slice)) +assert(["baseline", "physical", "vlan", "disabled", "mixed", "reconcile", "sql-journal", "guards", "coexistence", "alias", "gre"].includes(slice)) // Fail closed on restricted loopback before importing DB, runtime, crypto or // any other product module. This probe has no credentials and no remote target. const probe = createNetServer(socket => socket.end()) @@ -39,7 +39,9 @@ const { updateMcpSettings, createMcpConnection, authenticateMcpToken, setConnect const { invokeMcpTool, approvePlan, applyPlan } = await import("../modules/mcp/runtime.js") const { VRF_PRODUCTION_REQUIRED_CATALOGS } = await import("../services/vrf-production-admission.js") const { VRF_PRODUCTION_RAW_PATHS } = await import("../services/vrf-production-read.js") -const { decryptSecretExisting } = await import("../modules/mcp/security.js") +const { decryptSecretExisting, withVrfProductionSealingKey } = await import("../modules/mcp/security.js") +const { loadVerifiedVrfProductionHistory } = await import("../services/vrf-production-execution.js") +const { assertVrfProductionHistoryProvenance } = await import("../services/vrf-production-transition.js") const { findMcpOperation, registerMcpOperation } = await import("../modules/mcp/registry.js") type Row = Record const report = JSON.parse(await readFile(process.env.MMAPP_VRF_NATIVE_REPORT, "utf8")) @@ -48,6 +50,20 @@ const phases = slice === "disabled" ? [nativePhases[0], "disabled-create", "disa const createCommand = report.journal.find((value: { phase: string }) => value.phase === "vrf-create") const selectedNames = report.phases.find((value: { phase: string }) => value.phase === nativePhases[0]).owned.bridge.raw.map((row: Row) => String(row.name)) as string[] let coexistenceForeign = false +const deleteLoopName = "owned-gre-delete-loop" +function addSyntheticDeleteLoop(catalogs: Record, selected: boolean): void { + const target = catalogs.vrf.find(row => row.name !== "main"), table = selected ? String(target!.name) : "main" + if (selected) target!.interfaces = String(target!.interfaces) + "," + deleteLoopName + const mac = "02:00:00:00:00:30", id = "*ff30", gateway = deleteLoopName + (table === "main" ? "" : "@" + table) + catalogs.bridge.push({ ".id": id, name: deleteLoopName, disabled: "false", dynamic: "false", running: "true", "mac-address": mac, "protocol-mode": "none" }) + catalogs.interface_inventory.push({ ".id": id, name: deleteLoopName, type: "bridge", disabled: "false", dynamic: "false", running: "true", "mac-address": mac, vrf: table }) + catalogs.ipv4_address.push({ ".id": "*ff31", interface: deleteLoopName, "actual-interface": deleteLoopName, address: "198.18.246.17/32", dynamic: "false", disabled: "false", invalid: "false", vrf: table }) + catalogs.ipv4_route.push({ ".id": selected ? "*ff33" : "*ff35", "dst-address": "198.18.246.17/32", gateway, "immediate-gw": deleteLoopName, "local-address": "198.18.246.17%" + gateway, "routing-table": table, dynamic: "true", connect: "true", distance: "0", scope: "10", "target-scope": "5" }) + if (selected) { + catalogs.ipv6_address.push({ ".id": "*ff32", interface: deleteLoopName, "actual-interface": deleteLoopName, address: "fe80::ff:fe00:30/64", dynamic: "true", "auto-link-local": "true", "link-local": "true", disabled: "false", invalid: "false", vrf: "main" }) + catalogs.ipv6_route.push({ ".id": "*ff34", "dst-address": "fe80::/64", gateway, "immediate-gw": deleteLoopName, "routing-table": table, dynamic: "true", connect: "true", distance: "0", scope: "10", "target-scope": "5" }) + } +} function nativeCatalog(phase: string): Record { const record = report.phases.find((value: { phase: string }) => value.phase === phase) assert(record) @@ -92,7 +108,42 @@ function catalog(phase: string): Record { result.ipv4_route.push({ ".id": "*ffed", "dst-address": "203.0.113.0/24", gateway: "198.51.100.1", "routing-table": "main", dynamic: "false", disabled: "false" }) result.ip_filter.push({ ".id": "*ffec", chain: "forward", action: "drop", comment: "legitimate foreign firewall" }) } + if (slice === "gre") { + // Synthetic six-transition GRE lifecycle derived from the recorded bridge + // vectors. Only the remote-bound no-MAC auto-LL/endpoints match captured + // GRE observations; these lifecycle transitions are not native CHR proof. + result.gre = result.bridge.map((row, index) => ({ ".id": row[".id"], name: row.name, "local-address": `198.18.245.${index ? 13 : 9}`, "remote-address": `198.18.245.${index ? 14 : 10}` })) + for (const row of result.interface_inventory) if (selectedNames.includes(String(row.name))) { row.type = "gre-tunnel"; delete row["mac-address"] } + for (const row of result.ipv6_address) if (row.dynamic === "true" && selectedNames.includes(String(row.interface))) { + row.address = row.interface === selectedNames[0] ? "fe80::2:c612:f50a/64" : "fe80::2:c612:f50e/64" + // Synthetic context correction only; never assert native lifecycle proof. + row.vrf = result.interface_inventory.find(item => item.name === row.interface)!.vrf + } + for (const row of result.interface_inventory.filter(item => selectedNames.includes(String(item.name)))) { + // Same synthetic bounded-cycle model as the pure suite. It drives this + // existing real MCP/SQL/approval/REST profile, not a new test API or native proof. + let previous = "main", cycles = 0 + for (const step of nativePhases.slice(0, nativePhases.indexOf(phase) + 1)) { + const current = String(nativeCatalog(step).interface_inventory.find(item => item.name === row.name)!.vrf) + if (current !== previous) cycles++ + previous = current + } + const clock = (seconds: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString().slice(0, 19).replace("T", " ") + row["link-downs"] = String(cycles); row["last-link-up-time"] = clock(cycles * 2) + if (cycles) row["last-link-down-time"] = clock(cycles * 2 - 1) + else delete row["last-link-down-time"] + } + result.bridge = [] + result.wireguard = [{ ".id": "*ff11", name: "gre-source-wg", mtu: "1420", "listen-port": "55100", disabled: "false" }] + // Stable nonempty V30-shaped peer configuration, using a documentation IP. + // No keys, live underlay or native handshake are implied. The HTTP stub + // adds only synthetic changing peer telemetry to each GET response below. + result.wireguard_peer = [{ ".id": "*ff12", interface: "gre-source-wg", "endpoint-address": "198.51.100.1", "endpoint-port": "55101", "allowed-address": "198.18.245.10/32,198.18.245.14/32", "persistent-keepalive": "10s", disabled: "false" }] + result.interface_inventory.push({ ".id": "*ff11", name: "gre-source-wg", type: "wg", vrf: "main" }) + for (const [index, host] of [9, 13].entries()) result.ipv4_address.push({ ".id": `*ff2${index}`, interface: "gre-source-wg", address: `198.18.245.${host}/30`, dynamic: "false", disabled: "false", vrf: "main" }) + } if (slice === "alias") { const target = result.vrf.find(row => row.name !== "main"); if (target) target.comment = "MikrotikManager: owned alias fixture" } + if (slice === "gre" && phase === nativePhases.at(-1)) addSyntheticDeleteLoop(result, false) return result } let directDisabledDelete = false @@ -104,6 +155,7 @@ function command(index: number) { return { ...report.journal.find((value: { phase: string }) => value.phase === phases[index]), label: phases[index], after: phases[index] } } let state = catalog(slice === "alias" ? phases[4] : phases[0]), next = slice === "alias" ? 5 : 1, writes = 0, reads = 0 +let wireguardPeerTelemetryTicks = 0 let fault: "none" | "ignored" | "collateral" | "wrong-ack" | "lost-ack" | "post-intent-revoke" | "wrong-explicit-id" | "native-contract-drift" = "none" let connectionId = "", revokePlanId = "", revokeAfter: Date | undefined, revoked = false let lateRoute: Row | undefined @@ -138,6 +190,11 @@ const router = createServer((request, response) => { void (async () => { } if (slice === "alias" && path === "/routing/ospf/interface-template") { response.end(JSON.stringify(aliasOspfRows)); return } const resource = pathToResource.get(path) + if (slice === "gre" && resource === "wireguard_peer") { + wireguardPeerTelemetryTicks++ + assert(wireguardPeerTelemetryTicks <= 10000, "Synthetic peer telemetry tick budget") + response.end(JSON.stringify(state.wireguard_peer.map(row => ({ ...row, rx: String(wireguardPeerTelemetryTicks * 64), tx: String(wireguardPeerTelemetryTicks * 128), "last-handshake": `${wireguardPeerTelemetryTicks}s` })))); return + } response.end(JSON.stringify(resource ? [...state[resource] ?? [], ...(resource === "ipv4_route" && lateRoute ? [lateRoute] : [])] : [])); return } assert(path === "/ip/vrf" || path.startsWith("/ip/vrf/"), "Only the exact VRF native command is permitted") @@ -159,6 +216,12 @@ await new Promise(resolve => router.listen(0, "127.0.0.1", resolve)) const closeAgent = installLoopbackFixtureAgent(router) const actor: AuthUser = { id: "owned-vrf-executor", email: "vrf@example.invalid", name: "VRF owned acceptance", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 1800000 } const accepted: string[] = [] +const diagnosticStarted = performance.now() +// Disabled slice only: phase = positive ordinal * 100 + fixed stage below. +// At most 92 numeric stage records; no plan IDs, payloads or error text. +const disabledStage = (stage: number) => { + if (slice === "disabled") console.log(JSON.stringify({ phase: (accepted.length + 1) * 100 + stage, elapsedMs: Math.round(performance.now() - diagnosticStarted) })) +} try { await updateMcpSettings({ enabled: true }, actor) const serverId = Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,username,password,enabled) VALUES('owned-vrf-runtime','127.0.0.1',$1,false,'admin',$2,true) RETURNING id", [(router.address() as { port: number }).port, password])).rows[0].id) @@ -174,28 +237,67 @@ try { const selected = command(next) const action = selected.method === "PUT" ? "create" : selected.method === "DELETE" ? "delete" : "update" const fields = Object.fromEntries(Object.entries(selected.fields).map(([key, value]) => [key, key === "disabled" ? value === "true" : value])) + disabledStage(10) // prepare tool: before/after const result = await invokeMcpTool(principal, slice === "alias" ? "mm_vrf_plan" : `mm_vrf_${action}_plan`, slice === "alias" ? { serverId, id: selected.id, interfaces: String(fields.interfaces).split(",") } : { serverId, productionAdmission: true, ...(selected.id ? { id: selected.id } : {}), fields }) as { planId: string; vrfTicket: { ticketId: string } } + disabledStage(11) assert(result.vrfTicket?.ticketId) + disabledStage(20) // human approval: before/after await approvePlan(result.planId, actor, true) + disabledStage(21) return result } let lastOperationId = "" const positive = async (label: string) => { + const beforePeerTicks = wireguardPeerTelemetryTicks const prepared = await prepare(), beforeWrites = writes - const operation = await applyPlan(principal, prepared.planId, randomUUID()), terminal = await wait(operation.id) + disabledStage(30) // apply call: before/after + const operation = await applyPlan(principal, prepared.planId, randomUUID()) + disabledStage(31) + disabledStage(40) // terminal wait: before/after + const terminal = await wait(operation.id) + disabledStage(41) + disabledStage(50) // SQL/crypto/replay postverification: before/after assert.equal(terminal.state, "applied", JSON.stringify({ phase: label, error: terminal.error_code, result: terminal.result })) assert.equal(terminal.verification, "passed"); assert.equal(writes, beforeWrites + 1) const step = (await pool.query("SELECT * FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows[0] assert.equal(step.state, "verified"); assert.equal(step.result.configurationVerified, true); assert.equal(step.result.forwardingVerified, false) - const checkpoint = (await pool.query("SELECT after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1", [operation.id])).rows[0] + const checkpoint = (await pool.query("SELECT before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1", [operation.id])).rows[0] assert.equal(JSON.parse(await decryptSecretExisting(checkpoint.after_sealed)).kind, "vrf-production-private-checkpoint-v1") + if (slice === "gre") { + assert(wireguardPeerTelemetryTicks > beforePeerTicks, "Each GRE lifecycle phase must read changing peer telemetry") + const { before } = JSON.parse(await decryptSecretExisting(checkpoint.before_sealed)) + if (next === phases.length) { + // Synthetic external current-state addition before fresh DELETE approval; + // not a claimed earlier native action or historical checkpoint rewrite. + assert(before.catalogs.bridge.some((row: Row) => row.name === deleteLoopName)) + assert(before.catalogs.vrf.some((row: Row) => String(row.interfaces).split(",").includes(deleteLoopName))) + assert(before.catalogs.ipv6_address.some((row: Row) => row.interface === deleteLoopName)) + assert(!state.ipv6_address.some(row => row.interface === deleteLoopName)) + assert(state.ipv4_address.some(row => row.interface === deleteLoopName && row[".id"] === "*ff31")) + assert(state.ipv6_address.some(row => row.interface === selectedNames[1] && row.dynamic === "true"), "Prior GRE acquisition absence must still revive on DELETE") + } + await withVrfProductionSealingKey(async secret => { + const history = await loadVerifiedVrfProductionHistory({ sql: pool, ownerId: actor.id, serverId, sourceProof: before.sourceProof, nativeId: step.result.nativeId, secret }) + assert(history, "GRE verified SQL checkpoint must reopen") + assertVrfProductionHistoryProvenance(history, { ...before, catalogs: state }) + }) + } assert.equal((await pool.query("SELECT state FROM vrf_production_tickets WHERE ticket_id=$1", [prepared.vrfTicket.ticketId])).rows[0].state, "claimed") const replay = await applyPlan(principal, prepared.planId, randomUUID()); assert.equal(replay.id, operation.id); assert.equal(writes, beforeWrites + 1) + disabledStage(51) accepted.push(label) lastOperationId = operation.id - console.log(JSON.stringify({ slice, acceptedCase: label, nativeWrites: writes, configurationVerified: true, forwardingVerified: false })) + console.log(JSON.stringify({ slice, acceptedCase: label, nativeWrites: writes, ...(slice === "gre" ? { wireguardPeerTelemetryTicks } : {}), configurationVerified: true, forwardingVerified: false })) + } + for (let phase = 1; phase < (["sql-journal", "guards", "coexistence", "alias"].includes(slice) ? 1 : slice === "reconcile" ? 2 : phases.length); phase++) { + if (slice === "gre" && phase === phases.length - 1) { + // Model a separate administrator adding an empty loop bridge and selecting + // it after the first five actions. The sixth action gets a fresh approval + // and ticket for this complete current graph, with prior GRE debt intact. + addSyntheticDeleteLoop(state, true) + } + await positive(phases[phase]) } - for (let phase = 1; phase < (["sql-journal", "guards", "coexistence", "alias"].includes(slice) ? 1 : slice === "reconcile" ? 2 : phases.length); phase++) await positive(phases[phase]) if (slice === "alias") { await assert.rejects(invokeMcpTool(principal, "mm_vrf_update_plan", { serverId, id: command(next).id, productionAdmission: true, fields: { interfaces: selectedNames[1] } })) const target = state.vrf.find(row => row.name !== "main")!, comment = target.comment @@ -213,6 +315,9 @@ try { const oldPublic = await prepare() await pool.query("UPDATE mcp_plans SET operation_version=2 WHERE id=$1", [oldPublic.planId]) await assert.rejects(applyPlan(principal, oldPublic.planId, randomUUID())); assert.equal(writes, 0) + const oldV3Public = await prepare() + await pool.query("UPDATE mcp_plans SET operation_version=3 WHERE id=$1", [oldV3Public.planId]) + await assert.rejects(applyPlan(principal, oldV3Public.planId, randomUUID())); assert.equal(writes, 0) const originalNative = findMcpOperation("ros.vrf.update")!, nativeStale = await prepare() revokePlanId = nativeStale.planId; fault = "native-contract-drift" try { @@ -224,9 +329,9 @@ try { accepted.push("public-v2-and-fresh-native-version-fences-no-replay") await positive("alias-only-grant-actual-apply-canonical-tool-denied") const applied = (await pool.query("SELECT p.operation_id,p.operation_version,t.action_id,t.action_version,t.ciphertext FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id JOIN vrf_production_tickets t ON t.plan_id=p.id WHERE o.id=$1", [lastOperationId])).rows[0] - assert.equal(applied.operation_id, "routing.vrf.update"); assert.equal(applied.operation_version, 3); assert.equal(applied.action_id, "ros.vrf.update") + assert.equal(applied.operation_id, "routing.vrf.update"); assert.equal(applied.operation_version, 4); assert.equal(applied.action_id, "ros.vrf.update") const privateContext = JSON.parse(await decryptSecretExisting(applied.ciphertext)).context - assert.deepEqual(privateContext.authorityAction, { id: "routing.vrf.update", version: 3 }); assert.equal(privateContext.actionVersion, originalNative.version) + assert.deepEqual(privateContext.authorityAction, { id: "routing.vrf.update", version: 4 }); assert.equal(privateContext.actionVersion, originalNative.version) assert.equal(writes, 1) } if (slice === "coexistence") { @@ -307,7 +412,9 @@ try { console.log(JSON.stringify({ slice, acceptedCase: accepted.slice(1), nativeWrites: writes, readonly: true })) } if (slice === "disabled") { + disabledStage(0) // direct disabled-create/delete reset: before/after state = catalog(phases[0]); next = 1; directDisabledDelete = true + disabledStage(1) await positive("disabled-create-direct-delete"); await positive("disabled-delete") } if (["physical", "vlan", "mixed"].includes(slice)) { @@ -352,7 +459,8 @@ try { console.log(JSON.stringify({ acceptedCase: selected, nativeWrites: writes, operationState: terminal.state, forwardingVerified: false })) } assert(reads > 0) - console.log(JSON.stringify({ slice, rawShape: slice === "baseline" ? "authoritative recorded v5 bridge vectors" : slice === "disabled" ? "synthetic disabled-CREATE derived from v5 inactive vector; never-enabled defaults not actual CHR" : "synthetic physical/VLAN concrete-parent projection derived from v5 context vectors", accepted, writes, rawReads: reads, actualSql: true, actualCrypto: true, actualAuthPolicyApprovalRuntime: true, fakeVerifier: false, actualRouter: false, forwardingVerified: false })) + if (slice === "gre") assert(wireguardPeerTelemetryTicks > 6 && wireguardPeerTelemetryTicks <= 10000) + console.log(JSON.stringify({ slice, rawShape: slice === "baseline" ? "authoritative recorded v5 bridge vectors" : slice === "disabled" ? "synthetic disabled-CREATE derived from v5 inactive vector; never-enabled defaults not actual CHR" : slice === "gre" ? "synthetic no-MAC GRE lifecycle derived from v5 bridge vectors and captured remote-bound auto-LL; static WG source parent with native wg alias and synthetic ticking peer telemetry; not actual CHR lifecycle" : "synthetic physical/VLAN concrete-parent projection derived from v5 context vectors", accepted, writes, rawReads: reads, ...(slice === "gre" ? { wireguardPeerTelemetryTicks } : {}), actualSql: true, actualCrypto: true, actualAuthPolicyApprovalRuntime: true, fakeVerifier: false, actualRouter: false, forwardingVerified: false })) } finally { closeAgent(); await new Promise(resolve => router.close(() => resolve())); await closePool() } diff --git a/components/mcp/bridge-vlan-workflow.tsx b/components/mcp/bridge-vlan-workflow.tsx new file mode 100644 index 00000000..a537fe0b --- /dev/null +++ b/components/mcp/bridge-vlan-workflow.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Checkbox } from "@/components/ui/checkbox" +import { bridgeVlanDesiredSchema, type BridgeVlanDesired } from "@mmapp/contracts/bridge-vlan" +import type { McpApi } from "@/shared/api/mcp" + +type Row = Record +type Interface = { resource: string; id: string; name: string; disabled?: unknown } +type State = { bridges: Row[]; ports: Row[]; vlans: Row[]; interfaces: Interface[]; vlanInterfaces: Row[]; ipv4Addresses: Row[]; ipv6Addresses: Row[] } +type Reference = BridgeVlanDesired["ports"][number]["interface"] +const keyOf = (reference: Reference) => `${reference.resource}/${reference.id}` +const dynamic = (row: Row) => [true, "true", "yes"].includes(row.dynamic as boolean | string) +const text = (row: Row, field: string) => String(row[field] ?? "") +function boolean(row: Row, field: string): boolean { + if ([true, "true", "yes"].includes(row[field] as boolean | string)) return true + if ([false, "false", "no"].includes(row[field] as boolean | string)) return false + throw new Error(`Не подтверждено поле ${field} у записи ${text(row, ".id")}`) +} +function Choice({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { + return +} +const selectClass = "h-9 min-w-0 rounded-md border bg-background px-3 text-sm" + +function draftFor(state: State, bridgeId: string): BridgeVlanDesired { + const bridge = state.bridges.find(row => row[".id"] === bridgeId) + if (!bridge) throw new Error("Выберите существующий мост") + const name = text(bridge, "name") + const reference = (name: string): Reference => { + const matches = state.interfaces.filter(item => item.name === name) + if (matches.length !== 1) throw new Error(`Не подтверждён интерфейс ${name}; обновите состояние`) + return { resource: matches[0].resource, id: matches[0].id } + } + const members = (value: unknown) => { + if (value === undefined || value === "") return [] + if (typeof value !== "string") throw new Error("Не подтверждён список участников VLAN") + return value.split(",").map(name => reference(name.trim())) + } + return bridgeVlanDesiredSchema.parse({ + filtering: boolean(bridge, "vlan-filtering"), pvid: Number(bridge.pvid), + ports: state.ports.filter(row => row.bridge === name && !dynamic(row)).map(row => ({ + id: text(row, ".id"), interface: reference(text(row, "interface")), pvid: Number(row.pvid), + ingressFiltering: boolean(row, "ingress-filtering"), frameTypes: row["frame-types"], disabled: boolean(row, "disabled"), + ...(row.comment !== undefined ? { comment: row.comment } : {}), + })), + vlans: state.vlans.filter(row => row.bridge === name && !dynamic(row)).map(row => ({ + id: text(row, ".id"), vlanIds: text(row, "vlan-ids"), tagged: members(row.tagged), untagged: members(row.untagged), + disabled: boolean(row, "disabled"), ...(row.comment !== undefined ? { comment: row.comment } : {}), + })), removePortIds: [], removeVlanIds: [], + }) +} + +export function BridgeVlanWorkflow({ api, connectionId, serverId }: { api: McpApi; connectionId: string; serverId: string }) { + const [state, setState] = useState(null) + const [bridgeId, setBridgeId] = useState("") + const [draft, setDraft] = useState(null) + const [reload, setReload] = useState(0) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState("") + const [receipt, setReceipt] = useState("") + const scope = `${connectionId}/${serverId}` + const currentScope = useRef(scope) + const selectedBridge = useRef(bridgeId) + useEffect(() => { + const abort = new AbortController() + currentScope.current = scope + const load = async () => { + await Promise.resolve() + if (abort.signal.aborted) return + setState(null); setDraft(null); setReceipt(""); setError(""); setLoading(true) + try { + if (!connectionId || !serverId) return + const data = await api.bridgeVlanState(connectionId, serverId, abort.signal) + if (abort.signal.aborted) return + setState(data.state) + const selected = data.state.bridges.find(row => row[".id"] === selectedBridge.current) + if (selected) setDraft(draftFor(data.state, String(selected[".id"]))) + else { selectedBridge.current = ""; setBridgeId("") } + } catch (failure) { if (!abort.signal.aborted) setError(failure instanceof Error ? failure.message : "Не удалось прочитать состояние моста") } + finally { if (!abort.signal.aborted) setLoading(false) } + } + void load() + return () => { abort.abort(); currentScope.current = "" } + }, [api, connectionId, serverId, scope, reload]) + const edit = (change: (value: BridgeVlanDesired) => BridgeVlanDesired) => { + setDraft(value => value && change(value)); setReceipt(""); setError("") + } + const chooseBridge = (id: string) => { + selectedBridge.current = id + setBridgeId(id); setReceipt(""); setError(""); setDraft(null) + if (state && id) try { setDraft(draftFor(state, id)) } catch (failure) { setError(failure instanceof Error ? failure.message : "Не удалось загрузить мост") } + } + const prepare = async () => { + if (!draft) return + setBusy(true); setError(""); setReceipt("") + try { + const checked = bridgeVlanDesiredSchema.safeParse(draft) + if (!checked.success) throw new Error("Проверьте PVID (1–4094), диапазоны VLAN и выбранные интерфейсы") + const result = await api.bridgeVlanPlan(connectionId, serverId, bridgeId, checked.data) + if (currentScope.current === scope) setReceipt(result.planId) + } catch (failure) { if (currentScope.current === scope) setError(failure instanceof Error ? failure.message : "Не удалось подготовить план") } + finally { setBusy(false) } + } + const bridgeName = state?.bridges.find(row => row[".id"] === bridgeId)?.name + const options = state?.interfaces ?? [] + const portOptions = options.filter(item => ["ethernet", "vlan", "vxlan"].includes(item.resource) && !state?.ports.some(row => row.interface === item.name && row.bridge !== bridgeName)) + const nextPort = portOptions.find(item => !draft?.ports.some(port => keyOf(port.interface) === keyOf(item))) + const memberOptions = options.filter(item => item.resource === "bridge" && item.id === bridgeId || draft?.ports.some(port => keyOf(port.interface) === keyOf(item))) + const interfaceSelect = (label: string, value: Reference, onChange: (value: Reference) => void) => + const membership = (label: string, selected: Reference[], onChange: (value: Reference[]) => void) =>
{label}
{options.filter(item => memberOptions.includes(item) || selected.some(value => keyOf(value) === keyOf(item))).map(item => keyOf(value) === keyOf(item))} onChange={checked => onChange(checked ? [...selected, { resource: item.resource, id: item.id }] : selected.filter(value => keyOf(value) !== keyOf(item)))} />)}
+ + return
+

Мост, порты и VLAN

+

Подготовьте единый план VLAN filtering, PVID и участников. Проверяется доступ к управлению устройством. Без независимого канала используется основной адрес с обязательным подтверждением человеком. Подтверждение и применение выполняются отдельно в планах.

+ {error &&

{error}

} +
+ + +
+ {loading &&

Чтение состояния…

} + {draft && state &&
+
edit(value => ({ ...value, filtering }))} />
+

Статические порты

+ {draft.ports.map((port, index) =>
{port.id ?? "Новый порт"} +
{interfaceSelect("Интерфейс порта", port.interface, reference => edit(value => ({ ...value, ports: value.ports.map((item, i) => i === index ? { ...item, interface: reference } : item) })))} + +
+
edit(value => ({ ...value, ports: value.ports.map((item, i) => i === index ? { ...item, ingressFiltering } : item) }))} /> edit(value => ({ ...value, ports: value.ports.map((item, i) => i === index ? { ...item, disabled } : item) }))} />
+
)} + +

Статические участники VLAN

+ {draft.vlans.map((vlan, index) => { + const update = (patch: Partial) => edit(value => ({ ...value, vlans: value.vlans.map((item, i) => i === index ? { ...item, ...patch } : item) })) + return
{vlan.id ?? "Новая запись VLAN"} + + {membership("Tagged, включая CPU-порт моста при необходимости", vlan.tagged, tagged => update({ tagged }))} + {membership("Untagged", vlan.untagged, untagged => update({ untagged }))} +
update({ disabled })} />
+
+ })} + + {state.ports.filter(row => row.bridge === bridgeName && dynamic(row)).map(row =>

Динамический порт {text(row, "interface")} ({text(row, ".id")}) — только чтение

)} + {state.vlans.filter(row => row.bridge === bridgeName && dynamic(row)).map(row =>

Динамический VLAN {text(row, "vlan-ids")} ({text(row, ".id")}) — только чтение

)} + {(draft.removePortIds.length > 0 || draft.removeVlanIds.length > 0) &&

Явное удаление портов: {draft.removePortIds.join(", ") || "нет"}. Записей VLAN: {draft.removeVlanIds.join(", ") || "нет"}. Для отмены удаления перечитайте состояние.

} + +
} + {receipt &&

Подготовлен план {receipt}. Открыть планы для проверки и подтверждения

} +
+} diff --git a/components/mcp/mcp-workspace.tsx b/components/mcp/mcp-workspace.tsx index dc801aa5..e6fd5046 100644 --- a/components/mcp/mcp-workspace.tsx +++ b/components/mcp/mcp-workspace.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation" import { useTable, type ColumnDef } from "@tanstack/react-table" import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react" import { toast } from "sonner" -import { mcpConnectionCreateSchema, type McpConnection, type McpOperationDescriptor } from "@mmapp/contracts/mcp" +import { mcpConnectionCreateSchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp" import { routeAiControllerBlockReason, routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing" import { useDataSource } from "@/lib/data-source" import { can, getClaims, hasPermission } from "@/lib/auth" @@ -16,6 +16,8 @@ import { DatabaseRestore } from "./database-restore" import { PlanReview } from "./plan-review" import { ArtifactDownloads } from "./artifact-downloads" import { PollStorageCapacity } from "./poll-storage-capacity" +import { BridgeVlanWorkflow } from "./bridge-vlan-workflow" +import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permission-preview" import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame" import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" import { Badge } from "@/components/reui/badge" @@ -33,22 +35,56 @@ type Tab = keyof typeof tabs type RecordRow = Record function pretty(value: unknown) { return JSON.stringify(value, null, 2) } function date(value: unknown) { return value ? new Date(String(value)).toLocaleString("ru-RU") : "—" } +function connectionAccessDeadline(connection: McpConnection) { + return Math.min(Date.parse(connection.policy.expiresAt), Date.parse(connection.ownerValidUntil ?? "")) +} +function ConnectionAccessStatus({ connection }: { connection: McpConnection }) { + const [now, setNow] = useState(() => Date.now()) + const expires = Date.parse(connection.policy.expiresAt), ownerExpires = Date.parse(connection.ownerValidUntil ?? "") + useEffect(() => { + let timer: number | undefined + const tick = () => { + const current = Date.now(); setNow(current) + const next = [expires, ownerExpires].filter(value => Number.isFinite(value) && value > current).reduce((first, value) => Math.min(first, value), Infinity) + if (Number.isFinite(next)) timer = window.setTimeout(tick, Math.min(60000, Math.max(1, next - current + 1))) + } + tick() + return () => window.clearTimeout(timer) + }, [expires, ownerExpires]) + const [label, reason] = connection.policy.status === "revoked" ? ["Доступ отозван", "Подключение и его токены отозваны"] + : connection.policy.status === "paused" ? ["На паузе", "Новые вызовы подключения остановлены"] + : !connection.generationValid ? ["Доступ аннулирован", "Поколение доступа больше не действительно"] + : !Number.isFinite(expires) ? ["Срок не подтверждён", "Не удалось определить срок доступа"] + : expires <= now ? ["Срок доступа истёк", "Продлите срок в разрешениях подключения"] + : !Number.isFinite(ownerExpires) ? ["Права не подтверждены", "Срок подтверждения прав владельца отсутствует"] + : ownerExpires <= now ? ["Права владельца истекли", "Владельцу нужно подтвердить актуальные права"] + : ["Активен", "Срок доступа и права владельца действуют"] + return
{label}{reason}
+} function useQuery(load: (signal: AbortSignal) => Promise) { const [data, setData] = useState(null), [error, setError] = useState("") const [updatedAt, setUpdatedAt] = useState(null) const refresh = useCallback(() => { window.dispatchEvent(new Event("mcp-refresh")) }, []) useEffect(() => { - let alive = true, revision = 0, controller: AbortController | undefined - const run = async () => { - if (document.hidden) return - controller?.abort(); controller = new AbortController(); const requestRevision = ++revision + let alive = true, revision = 0, pending = false, controller: AbortController | undefined, deadline: number | undefined + const run = async (replace = false) => { + if (!alive || document.hidden || (pending && !replace)) return + controller?.abort(); window.clearTimeout(deadline); controller = new AbortController(); const requestRevision = ++revision + pending = true + deadline = window.setTimeout(() => { + if (!alive || requestRevision !== revision) return + revision++; pending = false; controller?.abort() + setError("Обновление не завершилось за минуту. Следующая попытка выполнится автоматически.") + }, 60000) try { const result = await load(controller.signal); if (alive && requestRevision === revision) { setData(result); setError(""); setUpdatedAt(new Date().toISOString()) } } catch (failure) { if (alive && requestRevision === revision && !controller.signal.aborted) setError(errorText(failure)) } + finally { if (requestRevision === revision) { window.clearTimeout(deadline); pending = false } } } queueMicrotask(() => { void run() }) const tick = () => { void run() }, timer = window.setInterval(tick, 5000) - window.addEventListener("mcp-refresh", tick); document.addEventListener("visibilitychange", tick) - return () => { alive = false; revision++; controller?.abort(); window.clearInterval(timer); window.removeEventListener("mcp-refresh", tick); document.removeEventListener("visibilitychange", tick) } + const reload = () => { void run(true) } + window.addEventListener("mcp-refresh", reload); document.addEventListener("visibilitychange", tick) + return () => { alive = false; revision++; controller?.abort(); window.clearTimeout(deadline); window.clearInterval(timer); window.removeEventListener("mcp-refresh", reload); document.removeEventListener("visibilitychange", tick) } }, [load]) return { data, error, refresh, updatedAt } } @@ -75,7 +111,7 @@ function DeviceCatalog({ api, connectionId }: { api: McpApi; connectionId: strin const catalog = useQuery(useCallback(signal => api.deviceCatalog(connectionId, signal), [api, connectionId])) const [serverId, setServerId] = useState(""), [resourceId, setResourceId] = useState("") const resource = catalog.data?.resources.find(resource => resource.id === resourceId) - return <>
{catalog.data?.servers.map(server => )}{catalog.data?.resources.filter(resource => resource.read).map(resource => )}
{catalog.data && !catalog.data.resources.some(resource => resource.read) &&

Подключению не выданы инструменты чтения интерфейсов.

}{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && } + return <>
{catalog.data?.servers.map(server => )}{catalog.data?.resources.filter(resource => resource.read).map(resource => )}
{catalog.data && !catalog.data.resources.some(resource => resource.read) &&

Подключению не выданы инструменты чтения интерфейсов.

}{resourceId === "bridge" && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && }{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && } } function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; connectionId: string; serverId: string; resource: DeviceResource }) { const [offset, setOffset] = useState(0), [editing, setEditing] = useState(null), [draft, setDraft] = useState>({}) @@ -90,12 +126,18 @@ function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } } const current = editing && editing !== "new" ? editing : null + const ethernetDisabledUnknown = resource.id === "ethernet" && current !== null && current.disabled === undefined + const editableFields = resource.fields.filter(field => + editing === "new" || field.key === "comment" || field.key in (current ?? {}) || (ethernetDisabledUnknown && field.key === "disabled"), + ) const protectedRow = current && ["dynamic", "builtin", "default"].some(key => current[key] === "true" || current[key] === true) const change = (key: string, value: unknown) => setDraft(fields => { const next = { ...fields }; if (value === undefined) delete next[key]; else next[key] = value; return next }) - return <>{receipt &&

{receipt}

}

Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.

{resource.create && }
String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} />
{editing &&

{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}

{current &&
Текущие свойства
{pretty(current)}
}{protectedRow ?

Системный или динамический объект доступен для чтения.

: <>

В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.

{resource.fields.filter(field => editing === "new" || field.key in (current ?? {})).map(field => {field.type === "boolean" || field.options ? : change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />})}
}
{!protectedRow && (editing === "new" ? resource.create : resource.update) && }{current && !protectedRow && resource.delete && }
} + return <>{receipt &&

{receipt}

}

Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.

{resource.create && }
String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} />
{editing &&

{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}

{current &&
Текущие свойства
{pretty(current)}
}{protectedRow ?

Системный или динамический объект доступен для чтения.

: <>

В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.

{editableFields.map(field => {field.type === "boolean" || field.options ? : change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />})}
+ {ethernetDisabledUnknown && draft.disabled !== undefined &&

Исходное состояние неизвестно. Для этого изменения обратный план недоступен.

} + }
{!protectedRow && (editing === "new" ? resource.create : resource.update) && }{current && !protectedRow && resource.delete && }
} } -function TokenDialog({ token, close }: { token: string; close: () => void }) { - return { if (!open) close() }}>Доступ созданСохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя. +function TokenDialog({ token, close, connectionName }: { token: string; close: () => void; connectionName?: string }) { + return { if (!open) close() }}>Доступ создан{connectionName && `Подключение «${connectionName}». `}Сохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя. } function ConnectionForm({ api, operations, editing, close, created }: { api: McpApi; operations: McpOperationDescriptor[]; editing?: McpConnection; close: () => void; created: (token?: string) => void }) { const [step, setStep] = useState(1), [busy, setBusy] = useState(false), [error, setError] = useState("") @@ -133,28 +175,110 @@ function Connections({ api, operations }: { api: McpApi; operations: McpOperatio const columns = useMemo[]>(() => [ { accessorKey: "name", header: "Подключение", size: 200 }, { id: "mode", header: "Режим", cell: ({ row }) => modeLabels[(row.original.policy as McpConnection["policy"]).mode] }, - { id: "status", header: "Состояние", cell: ({ row }) => {row.original.generationValid ? (row.original.policy as McpConnection["policy"]).status : "доступ аннулирован"} }, + { id: "status", header: "Состояние доступа", size: 260, cell: ({ row }) => }, + { id: "expiresAt", header: "Доступ до", size: 170, cell: ({ row }) => date((row.original.policy as McpConnection["policy"]).expiresAt) }, { accessorKey: "lastUsedAt", header: "Последний вызов", cell: ({ row }) => date(row.original.lastUsedAt) }, - { id: "ownerValidation", header: "Права владельца", size: 190, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return
До {date(connection.ownerValidUntil)}{connection.ownerId === getClaims()?.sub && }
} }, + { id: "ownerValidation", header: "Права владельца до", size: 190, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return
{date(connection.ownerValidUntil)}{connection.ownerId === getClaims()?.sub && }
} }, { id: "actions", header: "Действия", size: 350, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return admin &&
} }, ], [api, admin, act]) return <> setEditing("new")}>Выдать доступ}>

HTTP endpoint: адрес backend + /mcp. Для stdio используйте MCP_URL и MCP_TOKEN с командой npm --prefix backend run mcp:stdio.

{editing && setEditing(null)} created={value => { setEditing(null); if (value) setToken(value); query.refresh() }} />} setToken("")} /> } function AccessTools({ api }: { api: McpApi }) { const connections = useQuery(useCallback(signal => api.connections(signal), [api])) - const [selected, setSelected] = useState(""), [result, setResult] = useState(null), [token, setToken] = useState("") - const [operations, setOperations] = useState([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false) - const grantLoad = useCallback(async (signal: AbortSignal) => selected ? api.grants(selected, signal) : { grants: [] }, [api, selected]) - const grants = useQuery(grantLoad), connection = connections.data?.connections.find(value => value.id === selected) - const create = async () => { - if (!connection) return - try { - const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt))) - const issued = await api.grant(connection.id, { id: crypto.randomUUID(), startsAt: now.toISOString(), expiresAt: expires.toISOString(), scope: { serverIds: serverText.split(",").map(value => value.trim()).filter(Boolean).map(Number), operationIds: operations, allowGlobal: global } }) - setToken(issued.token); grants.refresh() - } catch (error) { toast.error(errorText(error)) } + const [selected, setSelected] = useState("") + const [grantBusy, setGrantBusy] = useState(false) + const [issued, setIssued] = useState<{ token: string; connectionName: string } | null>(null) + const grantLocked = useRef(false) + const connection = connections.data?.connections.find(value => value.id === selected) + + const releaseGrant = () => { + grantLocked.current = false + setGrantBusy(false) } - return { setSelected(value); setOperations([]); setServerText(""); setGlobal(false); setResult(null) }}>{connections.data?.connections.map(connection => )}{connection && <>

Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.

{result !== null &&
{pretty(result)}
}
setServerText(event.target.value)} placeholder="ID через запятую" /> setMinutes(Number(event.target.value))} />
{connection.policy.scope.allowGlobal && }
{connection.policy.scope.operationIds.map(id => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}
{grants.data?.grants.map(grant =>
До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"}
)}} setToken("")} />
+ const issue = async (grant: Parameters[1]) => { + if (!connection || grantLocked.current) return + grantLocked.current = true + setGrantBusy(true) + try { + const { token } = await api.grant(connection.id, grant) + setIssued({ token, connectionName: connection.name }) + connections.refresh() + } catch (error) { + releaseGrant() + toast.error(errorText(error)) + } + } + + return + + + + {connections.data?.connections.map(connection => )} + + {connection && } + {issued && { setIssued(null); releaseGrant() }} />} + +} + +function ConnectionAccessTools({ api, connection, issue, grantBusy }: { + api: McpApi + connection: McpConnection + issue: (grant: Parameters[1]) => Promise + grantBusy: boolean +}) { + const [result, setResult] = useState> | null>(null) + const [operations, setOperations] = useState([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false) + const connectionId = connection.id + const grants = useQuery(useCallback(signal => api.grants(connectionId, signal), [api, connectionId])) + const checkRevision = useRef(0) + useEffect(() => () => { checkRevision.current++ }, []) + + const check = async () => { + const revision = ++checkRevision.current + setResult(null) + try { + const readiness = await api.check(connectionId) + if (revision === checkRevision.current) setResult(readiness) + } catch (error) { + if (revision === checkRevision.current) toast.error(errorText(error)) + } + } + + const create = () => { + const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt))) + try { + void issue({ + id: crypto.randomUUID(), startsAt: now.toISOString(), expiresAt: expires.toISOString(), + scope: { serverIds: serverText.split(",").map(value => value.trim()).filter(Boolean).map(Number), operationIds: operations, allowGlobal: global }, + }) + } catch (error) { + toast.error(errorText(error)) + } + } + + return <> + +

Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.

+ + + {result !== null && <> + {result.protocolProbe?.capabilitiesRead &&

+ {({ passed: "Проверочное чтение возможностей прошло.", failed: "Проверочное чтение возможностей завершилось ошибкой.", skipped: "Проверочное чтение возможностей пропущено." })[result.protocolProbe.capabilitiesRead]} +

} +
{pretty(result)}
+ } +
+ setServerText(event.target.value)} placeholder="ID через запятую" /> + setMinutes(Number(event.target.value))} /> +
+ {connection.policy.scope.allowGlobal && } +
{connection.policy.scope.operationIds.map(id => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}
+ + {grants.data?.grants.map(grant =>
+ До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"} + +
)} + } function RoutingProfileForm({ api, editing, close }: { api: McpApi; editing?: RecordRow; close: () => void }) { const connections = useQuery(useCallback(signal => api.connections(signal), [api])) @@ -197,20 +321,37 @@ function CandidateProbeEditor({ candidates, update }: { candidates: CandidateFor } function RoutePathVerification({ api }: { api: McpApi }) { const profiles = useQuery(useCallback(signal => api.profiles(signal), [api])) - const [profileId, setProfileId] = useState(""), [candidateId, setCandidateId] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [result, setResult] = useState(null) + const [profileId, setProfileId] = useState(""), [candidateId, setCandidateId] = useState(""), [busy, setBusy] = useState(false) const [proofPurpose, setProofPurpose] = useState<"community" | "control-path">("community") + const [feedback, setFeedback] = useState<{ key: string; result: unknown } | { key: string; error: string } | null>(null) + const revision = useRef(0) const profile = profiles.data?.profiles.find(profile => profile.id === profileId), config = routeAiProfileSchema.safeParse(profile?.config) - const check = async () => { setBusy(true); setError(""); setResult(null); try { setResult(await api.checkRoutePath(profileId, candidateId, proofPurpose)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } } + const candidates = config.success ? config.data.candidates.filter(candidate => candidate.controlProbe) : [] + const canCheck = candidates.some(candidate => candidate.id === candidateId) + const selectionKey = JSON.stringify([profileId, profile?.version, candidateId, proofPurpose]) + const currentFeedback = feedback?.key === selectionKey ? feedback : null + const invalidate = () => { revision.current++; setFeedback(null) } + const check = async () => { + if (busy || !canCheck) return + const request = ++revision.current + setBusy(true); setFeedback(null) + try { + const result = await api.checkRoutePath(profileId, candidateId, proofPurpose) + if (request === revision.current) setFeedback({ key: selectionKey, result }) + } catch (failure) { + if (request === revision.current) setFeedback({ key: selectionKey, error: errorText(failure) }) + } finally { setBusy(false) } + } return - +
- { setProfileId(value); setCandidateId(""); setResult(null) }}>{profiles.data?.profiles.map(profile => )} - { setCandidateId(value); setResult(null) }}>{config.success && config.data.candidates.filter(candidate => candidate.controlProbe).map(candidate => )} - { setProofPurpose(value as typeof proofPurpose); setResult(null) }}> + { invalidate(); setProfileId(value); setCandidateId("") }}>{profiles.data?.profiles.map(profile => )} + { invalidate(); setCandidateId(value) }}>{candidates.map(candidate => )} + { invalidate(); setProofPurpose(value as typeof proofPurpose) }}>

{proofPurpose === "control-path" ? "Проверяется только контрольный путь. Результат не разрешает переключение Community и не подтверждает обратный путь." : "Проверка учитывает Community, таблицу маршрутизации и BGP. Применение изменений требует отдельного плана; обратный путь проверяется отдельно."}

- - {result !== null &&
{pretty(result)}
} + + {currentFeedback && "result" in currentFeedback &&
{pretty(currentFeedback.result)}
}
} function RoutingProfiles({ api }: { api: McpApi }) { @@ -248,21 +389,121 @@ function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "ope { accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") }, { id: "details", header: "", cell: ({ row }) => }, ], []) - const decision = async (approve: boolean) => { if (!selected) return; try { await api.decision(String(selected.id), approve); setSelected(null); query.refresh() } catch (error) { toast.error(errorText(error)) } } - return

Последнее успешное обновление: {date(query.updatedAt)}{query.error ? " · показаны ранее полученные данные" : ""}

{kind === "requests" && { setSearch(event.target.value); setCursor("") }} />} { setStatus(event.target.value); setCursor("") }} />
Найдено: {query.data?.total ?? "—"}
{ if (!open) setSelected(null) }}>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}Секретные поля скрыты. Подтверждение относится к этому содержимому.{kind === "plans" && selected ? :
{pretty(selected)}
}{kind === "operations" && selected && }{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && }{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") &&
}
+ const pendingPlanRequests = useRef(new Set()) + const [pendingPlans, setPendingPlans] = useState>(() => new Set()) + const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id)) + const submitPlan = async (action: "approve" | "reject" | "apply") => { + if (!selected) return + const plan = selected + const planId = String(plan.id) + const pending = pendingPlanRequests.current + if (pending.has(planId)) return + pending.add(planId) + setPendingPlans(new Set(pending)) + try { + if (action === "apply") { + await api.apply(planId) + toast.success("Операция принята; результат появится в списке операций") + } else { + await api.decision(planId, action === "approve") + } + setSelected(current => current === plan ? null : current) + query.refresh() + } catch (error) { + toast.error(errorText(error)) + } finally { + pending.delete(planId) + setPendingPlans(new Set(pending)) + } + } + return

Последнее успешное обновление: {date(query.updatedAt)}{query.error ? " · показаны ранее полученные данные" : ""}

{kind === "requests" && { setSearch(event.target.value); setCursor("") }} />} { setStatus(event.target.value); setCursor("") }} />
Найдено: {query.data?.total ?? "—"}
{ if (!open) setSelected(null) }}>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}Секретные поля скрыты. Подтверждение относится к этому содержимому.{kind === "plans" && selected ? :
{pretty(selected)}
}{kind === "operations" && selected && }{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && }{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") &&
}
} function RecordFilters({ kind }: { kind: string }) { const params = useSearchParams() const update = (field: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + field, value); else url.searchParams.delete(kind + "." + field); url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) } return
update("connectionId", event.target.value)} />{kind === "requests" && <> update("serverId", event.target.value)} /> update("userId", event.target.value)} />}{["from", "to"].map(field => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} />)}
} -function Permissions({ api }: { api: McpApi }) { - const connections = useQuery(useCallback(signal => api.connections(signal), [api])), protections = useQuery(useCallback(signal => api.protections(signal), [api])) - const [connectionId, setConnectionId] = useState(""), [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState(""), [result, setResult] = useState(null) - const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState(""), [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("") +function ConnectionPolicySimulation({ api, connection, operations }: { + api: McpApi + connection: McpConnection + operations: McpOperationDescriptor[] +}) { + const [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState("") + const [result, setResult] = useState(null) + const revision = useRef(0) + useEffect(() => () => { revision.current++ }, []) + + const available = operations.filter(operation => connection.policy.scope.operationIds.includes(operation.id)) + const operation = available.find(operation => operation.id === operationId) + const target = mcpTargetSchema.safeParse(operation?.scope === "servers" + ? { scope: "servers", serverIds: serverText.split(/[,\s]+/).filter(Boolean).map(value => /^\d+$/.test(value) ? Number(value) : NaN) } + : { scope: operation?.scope }) + const invalidate = () => { revision.current++; setResult(null) } + const preview = async () => { + if (!operation || !target.success) return + const current = ++revision.current + setResult(null) + try { + const response = await api.preview({ connectionId: connection.id, operationId: operation.id, target: target.data }) + if (revision.current === current) setResult(response.result) + } catch (error) { + if (revision.current === current) toast.error(errorText(error)) + } + } + + return <> +
+ { invalidate(); setOperationId(value); setServerText("") }}> + + {available.map(operation => )} + + {operation?.scope === "servers" && { invalidate(); setServerText(event.target.value) }} />} +
+ {operation &&

{{ connection: "Область операции — подключение", global: "Область операции — всё приложение", servers: "Область операции — выбранные серверы" }[operation.scope]}

} + + {result && } + +} + +function Permissions({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) { + const connections = useQuery(useCallback(signal => api.connections(signal), [api])) + const protections = useQuery(useCallback(signal => api.protections(signal), [api])) + const [connectionId, setConnectionId] = useState("") + const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState("") + const [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("") + const [protectionServerText, setProtectionServerText] = useState("") const selected = connections.data?.connections.find(connection => connection.id === connectionId) - const preview = async () => { if (!selected) return; try { const serverIds = serverText.split(/[,\s]+/).filter(Boolean).map(Number); const res = await api.preview({ operationId, policy: selected.policy, ownerPermissions: getClaims()?.permissions ?? [], target: serverIds.length ? { scope: "servers", serverIds } : { scope: "global" }, objects: [], protections: protections.data?.protections.map(item => item.object_ref) ?? [] }); setResult(res.result) } catch (error) { toast.error(errorText(error)) } } - return
{connections.data?.connections.map(connection => )}{selected?.policy.scope.operationIds.map(id => )} setServerText(event.target.value)} />
{result !== null &&
{pretty(result)}
}
{protections.data?.protections.map(item =>
{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}{item.reason}{can("mm:mcp:admin") && }
)}
{can("mm:mcp:admin") && <>
setKind(event.target.value)} /> setObjectId(event.target.value)} /> setReason(event.target.value)} />
}
+ const protectionItems = protections.data?.protections ?? [] + const protectionServerId = Number(protectionServerText) + const validProtectionServer = /^\d+$/.test(protectionServerText) && Number.isSafeInteger(protectionServerId) && protectionServerId > 0 + const addProtection = () => { + void api.protect({ kind, id: objectId, protection, ...(kind === "server" ? {} : { serverId: protectionServerId }) }, reason) + .then(protections.refresh, error => toast.error(errorText(error))) + } + return
+ + + + + {connections.data?.connections.map(connection => )} + + {selected && } + {selected && selected.ownerId === getClaims()?.sub && } + + +
{protectionItems.map(item =>
{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}{item.reason}{can("mm:mcp:admin") && }
)}
+ {can("mm:mcp:admin") && <> +
+ setKind(event.target.value)} /> + setObjectId(event.target.value)} /> + {kind !== "server" && setProtectionServerText(event.target.value)} />} + + setReason(event.target.value)} /> +
+ + } +
+
} function OperationRecovery({ api }: { api: McpApi }) { const [id, setId] = useState(""), [result, setResult] = useState(null), [error, setError] = useState(""), [busy, setBusy] = useState(false) @@ -304,15 +545,29 @@ function Consent({ api, id }: { api: McpApi; id: string }) { }, []) const currentRequest = !request.error && request.data?.id === id ? request.data : null const ownerId = getClaims()?.sub - const eligible = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active") ?? [] - const canSubmit = currentRequest !== null && eligible.some(connection => connection.id === selected) + const [now, setNow] = useState(() => Date.now()) + const active = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active" && connection.generationValid) ?? [] + const nextExpiry = active.reduce((earliest, connection) => { + const deadline = connectionAccessDeadline(connection) + return deadline > now ? Math.min(earliest, deadline) : earliest + }, Infinity) + useEffect(() => { + if (!Number.isFinite(nextExpiry)) return + const delay = Math.max(0, nextExpiry - Date.now() + 1) + const timer = window.setTimeout(() => setNow(Date.now()), Math.min(delay, 60000)) + return () => window.clearTimeout(timer) + }, [nextExpiry, now]) + const eligible = active.filter(connection => connectionAccessDeadline(connection) > now) + const canDeny = currentRequest !== null + const canApprove = canDeny && eligible.some(connection => connection.id === selected) const isCurrentLocation = () => mounted.current && new URLSearchParams(window.location.search).get("oauthRequest") === id const confirm = async (approve: boolean) => { - if (!canSubmit || submitting.current || !isCurrentLocation()) return + if (!(approve ? canApprove : canDeny) || submitting.current || !isCurrentLocation()) return + if (approve && !eligible.some(connection => connection.id === selected && connectionAccessDeadline(connection) > Date.now())) return submitting.current = true setBusy(true) try { - const result = await api.consent(id, selected, approve) + const result = await api.consent(id, approve ? { approve: true, connectionId: selected } : { approve: false }) if (isCurrentLocation()) window.location.assign(result.redirectUrl) } catch (error) { if (!mounted.current) return @@ -329,8 +584,8 @@ function Consent({ api, id }: { api: McpApi; id: string }) { {eligible.map(connection => )}
- - + +
} @@ -346,5 +601,5 @@ function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: b const [operations, setOperations] = useState([]), [error, setError] = useState("") useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live]) const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) } - return <>

MCP

Доступ нейросетей, разрешения и контроль изменений

{!live ?

Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.

: <>{oauthRequestId && }
{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => {title})}
{can("mm:mcp:admin") && }
{can("mm:mcp:admin") &&
{can("mm:settings:admin") && }
}
}
+ return <>

MCP

Доступ нейросетей, разрешения и контроль изменений

{!live ?

Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.

: <>{oauthRequestId && }
{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => {title})}
{can("mm:mcp:admin") && }
{can("mm:mcp:admin") &&
{can("mm:settings:admin") && }
}
}
} diff --git a/components/mcp/object-permission-preview.tsx b/components/mcp/object-permission-preview.tsx new file mode 100644 index 00000000..fcbf49de --- /dev/null +++ b/components/mcp/object-permission-preview.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useEffect, useState } from "react" +import { mcpObjectRefSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp" +import type { McpApi } from "@/shared/api/mcp" +import { Badge } from "@/components/reui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type Catalog = Awaited> +type Rows = Awaited> +export function ObjectPermissionPreview({ api, connection, operations, editable, onProtected }: { + api: McpApi; connection: McpConnection; operations: McpOperationDescriptor[]; editable: boolean; onProtected: () => void +}) { + const [catalog, setCatalog] = useState(null), [rows, setRows] = useState(null) + const [server, setServer] = useState(""), [resource, setResource] = useState(""), [nativeId, setNativeId] = useState("") + const [operation, setOperation] = useState(""), [offset, setOffset] = useState(0), [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("") + const [error, setError] = useState(""), [busy, setBusy] = useState(false), [result, setResult] = useState(null), [receipt, setReceipt] = useState("") + useEffect(() => { + const controller = new AbortController() + api.deviceCatalog(connection.id, controller.signal).then(value => { if (!controller.signal.aborted) setCatalog(value) }, failure => { if (!controller.signal.aborted) setError(String(failure.message ?? failure)) }) + return () => controller.abort() + }, [api, connection.id]) + useEffect(() => { + if (!server || !resource) return + const controller = new AbortController() + api.deviceRows(connection.id, server, resource, offset, controller.signal).then(value => { if (!controller.signal.aborted) setRows(value) }, failure => { if (!controller.signal.aborted) setError(String(failure.message ?? failure)) }) + return () => controller.abort() + }, [api, connection.id, server, resource, offset]) + const reset = () => { setNativeId(""); setRows(null); setResult(null); setReceipt(""); setError("") } + const changeServer = (value: string) => { reset(); setServer(value); setOffset(0) } + const changeResource = (value: string) => { reset(); setResource(value); setOffset(0) } + const availableRows = (rows?.items ?? []).filter(row => typeof row[".id"] === "string" && row[".id"] !== "") + const selected = availableRows.find(row => row[".id"] === nativeId) + const ready = !!selected && catalog?.servers.some(value => String(value.id) === server) && catalog.resources.some(value => value.id === resource && value.read) + const act = async (save: boolean) => { + if (!ready) return + setBusy(true); setError(""); setResult(null); setReceipt("") + try { + const object = mcpObjectRefSchema.parse({ kind: resource, id: nativeId, serverId: Number(server) }) + if (save) { await api.protect({ ...object, protection }, reason); onProtected(); setReceipt(`Защита сохранена: сервер ${server}, ${resource}, ${nativeId}.`) } + else { + setResult((await api.preview({ connectionId: connection.id, operationId: operation, target: { scope: "servers", serverIds: [Number(server)] }, objects: [object], objectCount: 1 })).result) + } + } catch (failure) { setError(failure instanceof Error ? failure.message : "Не удалось проверить объект") } finally { setBusy(false) } + } + const selectClass = "h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" + return
+

Объект устройства

+

Выберите объект по данным своего подключения. Проверка гипотетическая: она учитывает выбранный объект и сохранённые защиты. Реальный план дополнительно проверяет зависимости. Выбор объекта не ограничивает токен или временный допуск этим объектом.

+ {error &&

{error}

} +
+ + + + +
+
+ {result && } + {editable && <>} + {receipt &&

{receipt}

} +
+} + +export function PolicyPreviewDecision({ result }: { result: McpPolicyDecision }) { + return
+ {result.decision === "allowed" ? "Разрешено" : result.decision === "denied" ? "Запрещено" : "Нужно подтверждение"} + {result.reasons.length > 0 &&
    {result.reasons.map((item, index) =>
  • {item.message} ({item.code})
  • )}
} +

Лимиты подключения: {result.limits.callsPerMinute} вызовов/мин · {result.limits.concurrentReads} параллельных чтения · {result.limits.mutationsPerHour} изменений/ч · до {result.limits.serversPerPlan} серверов и {result.limits.objectsPerPlan} объектов в плане.

+
+} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 3fcf34cc..83a6bf95 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,10 @@ "build": "tsc -p tsconfig.json" }, "exports": { + "./bridge-vlan": { + "types": "./dist/bridge-vlan.d.ts", + "default": "./dist/bridge-vlan.js" + }, "./mcp-routing": { "types": "./dist/mcp-routing.d.ts", "default": "./dist/mcp-routing.js" diff --git a/packages/contracts/src/bridge-vlan.ts b/packages/contracts/src/bridge-vlan.ts new file mode 100644 index 00000000..6f44995c --- /dev/null +++ b/packages/contracts/src/bridge-vlan.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +export const bridgeVlanRowIdSchema = z.string().regex(/^\*[0-9a-f]+$/i) +export const bridgeVlanInterfaceReferenceSchema = z.object({ resource: z.string().min(1).max(64), id: bridgeVlanRowIdSchema }).strict() +export const bridgeVlanIdSchema = z.number().int().min(1).max(4094) +export const bridgeVlanFrameTypesSchema = z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]) +/** Complete desired static state. Omissions never authorize deletion. */ +export const bridgeVlanDesiredSchema = z.object({ + filtering: z.boolean(), pvid: bridgeVlanIdSchema, + frameTypes: bridgeVlanFrameTypesSchema.optional(), ingressFiltering: z.boolean().optional(), + ports: z.array(z.object({ id: bridgeVlanRowIdSchema.optional(), interface: bridgeVlanInterfaceReferenceSchema, pvid: bridgeVlanIdSchema, ingressFiltering: z.boolean(), frameTypes: bridgeVlanFrameTypesSchema, disabled: z.boolean(), comment: z.string().max(1000).optional() }).strict()).max(1024), + vlans: z.array(z.object({ id: bridgeVlanRowIdSchema.optional(), vlanIds: z.string().min(1).max(16384), tagged: z.array(bridgeVlanInterfaceReferenceSchema).max(1024), untagged: z.array(bridgeVlanInterfaceReferenceSchema).max(1024), disabled: z.boolean(), comment: z.string().max(1000).optional() }).strict()).max(4094), + removePortIds: z.array(bridgeVlanRowIdSchema).max(1024), removeVlanIds: z.array(bridgeVlanRowIdSchema).max(4094), +}).strict() +export const bridgeVlanConfigurationRequestSchema = z.object({ serverId: z.number().int().positive(), bridgeId: bridgeVlanRowIdSchema, desired: bridgeVlanDesiredSchema }).strict() +export type BridgeVlanDesired = z.infer +export type BridgeVlanConfigurationRequest = z.infer +const rows = z.array(z.record(z.string(), z.unknown())) +export const bridgeVlanStateSchema = z.object({ + bridges: rows, ports: rows, vlans: rows, vlanInterfaces: rows, ipv4Addresses: rows, ipv6Addresses: rows, + interfaces: z.array(z.object({ resource: z.string(), id: z.string(), name: z.string(), disabled: z.unknown().optional() }).strict()), +}).strict() +export type BridgeVlanState = z.infer diff --git a/shared/api/mcp.ts b/shared/api/mcp.ts index df3555c3..0f8e524a 100644 --- a/shared/api/mcp.ts +++ b/shared/api/mcp.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { bridgeVlanStateSchema, type BridgeVlanDesired } from "@mmapp/contracts/bridge-vlan" import { mcpCatalogResponseSchema, mcpConnectionsSchema, mcpSettingsSchema, mcpPolicyPreviewResponseSchema, mcpPollStorageCapacitySchema, type McpConnectionCreate } from "@mmapp/contracts/mcp" import { requestJson } from "./http-client" import { downloadHumanArtifact } from "./mcp-artifact" @@ -24,6 +25,8 @@ export function mcpApi(base: string) { deviceCatalog: (connectionId: string, signal?: AbortSignal) => get("/devices/catalog?" + new URLSearchParams({ connectionId }), deviceCatalogSchema, signal), deviceRows: (connectionId: string, serverId: string, resource: string, offset = 0, signal?: AbortSignal) => get(`/devices/${encodeURIComponent(serverId)}/${encodeURIComponent(resource)}?` + new URLSearchParams({ connectionId, offset: String(offset) }), z.object({ items: z.array(z.record(z.string(), z.unknown())), total: z.number(), nextOffset: z.number().nullable() }), signal), devicePlan: (connectionId: string, serverId: string, resource: string, action: "create" | "update" | "delete", id: string | undefined, fields: Record) => send(`/devices/${encodeURIComponent(serverId)}/${encodeURIComponent(resource)}/plan`, { connectionId, action, id, fields }, z.object({ planId: z.string(), state: z.string() })), + bridgeVlanState: (connectionId: string, serverId: string, signal?: AbortSignal) => get(`/devices/${encodeURIComponent(serverId)}/bridge-vlan/configuration?` + new URLSearchParams({ connectionId }), z.object({ state: bridgeVlanStateSchema }), signal), + bridgeVlanPlan: (connectionId: string, serverId: string, bridgeId: string, desired: BridgeVlanDesired) => send(`/devices/${encodeURIComponent(serverId)}/bridge-vlan/configuration/plan`, { connectionId, bridgeId, desired }, z.object({ planId: z.string(), state: z.string() })), revalidate: (id: string) => send("/connections/" + id + "/revalidate", {}, mcpConnectionsSchema.shape.connections.element), apply: (id: string) => send("/plans/" + id + "/apply", {}, z.object({ id: z.string(), state: z.string() })), profilePlan: (input: unknown) => send("/routing-profiles/plan", input, z.object({ planId: z.string(), state: z.string() })), @@ -36,7 +39,7 @@ export function mcpApi(base: string) { update: (id: string, input: McpConnectionCreate) => send("/connections/" + id, input, mcpConnectionsSchema.shape.connections.element, "PUT"), status: (id: string, status: "active" | "paused" | "revoked") => send(`/connections/${id}/status`, { status }, z.object({ ok: z.literal(true) })), rotate: (id: string) => send(`/connections/${id}/rotate`, {}, z.object({ token: z.string() })), - check: (id: string) => send(`/connections/${id}/check`, {}, z.object({ ok: z.boolean(), checks: z.record(z.string(), z.boolean()), checkedAt: z.string(), protocolProbe: z.object({ status: z.enum(["passed", "failed", "skipped"]), elapsedMs: z.number(), reason: z.string().optional(), credentialType: z.literal("temporary-no-operations"), clientCredentialsVerified: z.literal(false) }).optional() })), + check: (id: string) => send(`/connections/${id}/check`, {}, z.object({ ok: z.boolean(), checks: z.record(z.string(), z.boolean()), checkedAt: z.string(), protocolProbe: z.object({ status: z.enum(["passed", "failed", "skipped"]), elapsedMs: z.number(), reason: z.string().optional(), credentialType: z.enum(["temporary-no-operations", "temporary-capabilities-read"]), capabilitiesRead: z.enum(["passed", "failed", "skipped"]).optional(), clientCredentialsVerified: z.literal(false) }).optional() })), grants: (id: string, signal?: AbortSignal) => get(`/connections/${id}/grants`, z.object({ grants: z.array(z.object({ id: z.string(), grant: z.record(z.string(), z.unknown()), revoked_at: z.string().nullable() })) }), signal), grant: (id: string, grant: unknown) => send(`/connections/${id}/grants`, grant, z.object({ token: z.string(), expiresAt: z.string() })), revokeGrant: (id: string) => send(`/grants/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"), @@ -51,7 +54,7 @@ export function mcpApi(base: string) { createClient: (name: string, redirectUris: string[]) => send("/oauth/clients", { name, redirectUris }, z.object({ clientId: z.string() })), disableClient: (id: string) => send(`/oauth/clients/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"), consentRequest: (id: string, signal?: AbortSignal) => get("/oauth/requests/" + id, z.object({ id: z.string(), name: z.string(), redirect_uri: z.string(), scope: z.string() }), signal), - consent: (requestId: string, connectionId: string, approve: boolean) => send("/oauth/consent", { requestId, connectionId, approve }, z.object({ redirectUrl: z.string().url() })), + consent: (requestId: string, decision: { approve: true; connectionId: string } | { approve: false }) => send("/oauth/consent", { requestId, ...decision }, z.object({ redirectUrl: z.string().url() })), protections: (signal?: AbortSignal) => get("/protections", z.object({ protections: z.array(z.object({ id: z.string(), object_ref: z.object({ kind: z.string(), id: z.string(), serverId: z.number().optional(), protection: z.enum(["deny", "human-approval-required"]) }), reason: z.string() })) }), signal), protect: (object: unknown, reason: string) => send("/protections", { object, reason }, z.object({ id: z.string() })), removeProtection: async (id: string) => requestJson(base, "/api/mcp/protections/" + id, { method: "DELETE" }), diff --git a/tests/e2e/bridge-vlan/mcp-context.spec.ts b/tests/e2e/bridge-vlan/mcp-context.spec.ts new file mode 100644 index 00000000..abb99e65 --- /dev/null +++ b/tests/e2e/bridge-vlan/mcp-context.spec.ts @@ -0,0 +1,177 @@ +import { test, expect, type Page, type Route } from "@playwright/test" +import { bridgeVlanDesiredSchema } from "../../../packages/contracts/src/bridge-vlan" + +const connectionId = "fa58fa40-4bcb-4616-9316-cb69463b3150" +const planId = "6b55ae20-2500-489a-af90-3c7345bf6267" +const at = "2026-10-17T00:00:00Z" +const connection = { id: connectionId, name: "VLAN editor", ownerId: "ui-admin", policy: { id: connectionId, version: 1, status: "active", mode: "approval", expiresAt: "2099-01-01T00:00:00Z", permissions: ["mm:network:read", "mm:network:write"], scope: { serverIds: [1, 2], operationIds: ["network.bridge-vlan.read", "network.bridge-vlan.configure"], allowGlobal: false }, dangerousPolicy: "approval", autonomousDangerousOperationIds: [], limits: { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 } }, createdAt: at, updatedAt: at, lastUsedAt: null, generationValid: true } +function fixture() { + return { + bridges: [{ ".id": "*1", name: "br-users", pvid: "1", "vlan-filtering": "false", disabled: "false" }, { ".id": "*2", name: "br-other", pvid: "99", "vlan-filtering": "false", disabled: "false" }], + interfaces: [{ resource: "bridge", id: "*1", name: "br-users" }, { resource: "bridge", id: "*2", name: "br-other" }, ...[2, 3, 4, 5].map(n => ({ resource: "ethernet", id: `*${n + 1}`, name: `ether${n}`, disabled: "false" })), { resource: "wireguard", id: "*7", name: "wg-l3" }, { resource: "gre", id: "*8", name: "gre-l3" }], + ports: [ + { ".id": "*10", bridge: "br-users", interface: "ether2", pvid: "10", "ingress-filtering": "true", "frame-types": "admit-all", disabled: "false", comment: "preserve port" }, + { ".id": "*11", bridge: "br-users", interface: "ether3", pvid: "1", "ingress-filtering": "true", "frame-types": "admit-only-vlan-tagged", disabled: "false" }, + { ".id": "*12", bridge: "br-users", interface: "ether5", dynamic: "true" }, + ], + vlans: [ + { ".id": "*20", bridge: "br-users", "vlan-ids": "10", tagged: "br-users", untagged: "ether2", disabled: "false", comment: "preserve VLAN" }, + { ".id": "*21", bridge: "br-users", "vlan-ids": "30-32", tagged: "br-users,ether3", untagged: "", disabled: "false" }, + { ".id": "*22", bridge: "br-users", "vlan-ids": "1", dynamic: "true" }, + ], vlanInterfaces: [], ipv4Addresses: [], ipv6Addresses: [], + } +} +type Calls = { path: string; method: string; body: unknown }[] +async function setup(page: Page, stateRoute?: (route: Route, server: string) => Promise) { + const calls: Calls = [] + await page.addInitScript(() => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "ui-admin", apps: ["mm"], permissions: ["mm:settings:admin", "mm:servers:read", "mm:network:admin"], exp: 4102444800 })) + ".signature")) + await page.route("**/api/**", async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname, method = request.method() + calls.push({ path, method, body: method === "POST" ? request.postDataJSON() : null }) + if (method === "OPTIONS") return route.fulfill({ status: 204 }) + const json = (body: unknown) => route.fulfill({ json: body }) + if (path === "/api/auth/config") return json({ required: false }) + if (path === "/api/mcp/connections") return json({ connections: [connection] }) + if (path === "/api/mcp/catalog") return json({ stage: "runtime", executionAvailable: true, operations: [] }) + if (path === "/api/mcp/devices/catalog") return json({ servers: [1, 2].map(id => ({ id, name: `CHR ${id}`, host: `192.0.2.${id}`, enabled: true })), resources: [{ id: "bridge", title: "Bridge", read: true, create: false, update: false, delete: false, fields: [] }] }) + const state = path.match(/^\/api\/mcp\/devices\/(\d+)\/bridge-vlan\/configuration$/) + if (state) { + expect(url.searchParams.get("connectionId")).toBe(connectionId) + return stateRoute ? stateRoute(route, state[1]) : json({ state: fixture() }) + } + if (path.endsWith("/bridge-vlan/configuration/plan")) return json({ planId, state: "awaiting-approval" }) + if (/\/devices\/\d+\/bridge$/.test(path)) return json({ items: [], total: 0, nextOffset: null }) + if (path === "/api/mcp/overview") return json({ connections: 1, calls: 0, errors: 0, approvals: 0, running: 0, enabled: true, observedAt: at }) + if (path === "/api/mcp/coverage") return json({ version: 1, routes: [] }) + if (path === "/api/servers") return json([]) + return json({ items: [], total: 0, nextCursor: null }) + }) + return calls +} +async function open(page: Page) { + await page.goto("/mcp?tab=devices") + await page.getByLabel("Подключение для управления интерфейсами").selectOption(connectionId) + await page.getByLabel("Сервер интерфейсов").selectOption("1") + await page.getByLabel("Тип объектов устройства").selectOption("bridge") + const editor = page.getByRole("region", { name: "Связанная настройка Bridge VLAN" }) + await expect(editor.getByLabel("Существующий мост")).toBeEnabled() + return editor +} + +test("связанный VLAN: полный desired, явные удаления и только подготовка плана", async ({ page }) => { + const calls = await setup(page), editor = await open(page) + await editor.getByLabel("Существующий мост").selectOption("*1") + await expect(editor.getByRole("group", { name: "*10", exact: true })).toBeVisible() + await expect(editor.getByText("Динамический порт ether5", { exact: false })).toBeVisible() + await expect(editor.getByText("Динамический VLAN 1", { exact: false })).toBeVisible() + await expect(editor.getByRole("group", { name: "*12", exact: true })).toHaveCount(0) + await editor.getByRole("checkbox", { name: "VLAN filtering", exact: true }).check() + await editor.getByLabel("PVID моста", { exact: true }).fill("2") + await editor.getByRole("group", { name: "*10", exact: true }).getByLabel("PVID порта").fill("20") + await editor.getByRole("group", { name: "*20", exact: true }).getByRole("textbox").fill("20") + await editor.getByRole("group", { name: "*11", exact: true }).getByRole("button", { name: "Удалить порт из плана" }).click() + await editor.getByRole("group", { name: "*21", exact: true }).getByRole("button", { name: "Удалить запись VLAN из плана" }).click() + await editor.getByRole("button", { name: "Добавить порт", exact: true }).click() + const newPort = editor.getByRole("group", { name: "Новый порт", exact: true }) + await expect(newPort.getByRole("option", { name: /wg-l3|gre-l3|br-users/ })).toHaveCount(0) + await newPort.getByLabel("Интерфейс порта").selectOption("ethernet/*5") + await newPort.getByLabel("Типы кадров").selectOption("admit-only-vlan-tagged") + await editor.getByRole("button", { name: "Добавить запись VLAN", exact: true }).click() + const newVlan = editor.getByRole("group", { name: "Новая запись VLAN", exact: true }) + await newVlan.getByRole("textbox").fill("30-32") + const tagged = newVlan.getByRole("group", { name: "Tagged, включая CPU-порт моста при необходимости" }) + await tagged.getByRole("checkbox", { name: "br-users", exact: true }).check() + await tagged.getByRole("checkbox", { name: "ether4", exact: true }).check() + await expect(editor).toContainText("Явное удаление портов: *11. Записей VLAN: *21") + await editor.screenshot({ path: "tmp/mcp-scoped-controller-two-chr-20261105/bridge-vlan-ui-edge.png" }) + await page.setViewportSize({ width: 390, height: 844 }) + expect(await editor.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const bridgeSelect = editor.getByLabel("Существующий мост") + expect((await bridgeSelect.boundingBox())!.width).toBeGreaterThanOrEqual(150) + const heading = editor.getByRole("heading", { name: "Мост, порты и VLAN", exact: true }) + await heading.evaluate(element => element.scrollIntoView({ block: "center", behavior: "instant" })) + await expect.poll(async () => (await heading.boundingBox())!.y).toBeGreaterThanOrEqual(48) + await expect(bridgeSelect).toBeInViewport() + await page.screenshot({ path: "tmp/mcp-scoped-controller-two-chr-20261105/bridge-vlan-ui-edge-mobile.png" }) + await editor.getByRole("button", { name: "Подготовить связанный план VLAN" }).click() + await expect(editor.getByRole("status")).toContainText(planId) + await expect(editor.getByRole("link", { name: "Открыть планы для проверки и подтверждения" })).toHaveAttribute("href", "/mcp?tab=plans") + const posts = calls.filter(call => call.method === "POST") + expect(posts).toHaveLength(1) + expect(posts[0].path).toBe("/api/mcp/devices/1/bridge-vlan/configuration/plan") + const body = posts[0].body as { connectionId: string; bridgeId: string; desired: unknown } + expect(body.connectionId).toBe(connectionId); expect(body.bridgeId).toBe("*1") + const desired = bridgeVlanDesiredSchema.parse(body.desired) + expect(desired).toEqual({ filtering: true, pvid: 2, + ports: [{ id: "*10", interface: { resource: "ethernet", id: "*3" }, pvid: 20, ingressFiltering: true, frameTypes: "admit-all", disabled: false, comment: "preserve port" }, { interface: { resource: "ethernet", id: "*5" }, pvid: 1, ingressFiltering: true, frameTypes: "admit-only-vlan-tagged", disabled: false }], + vlans: [{ id: "*20", vlanIds: "20", tagged: [{ resource: "bridge", id: "*1" }], untagged: [{ resource: "ethernet", id: "*3" }], disabled: false, comment: "preserve VLAN" }, { vlanIds: "30-32", tagged: [{ resource: "bridge", id: "*1" }, { resource: "ethernet", id: "*5" }], untagged: [], disabled: false }], removePortIds: ["*11"], removeVlanIds: ["*21"], + }) + await editor.getByLabel("PVID моста", { exact: true }).fill("3") + await expect(editor.getByRole("status")).toHaveCount(0) + expect(calls.filter(call => /\/apply|\/approve|\/bridge\/plan$/.test(call.path))).toHaveLength(0) +}) + +test("связанный VLAN: смена моста и перечитывание сбрасывают только черновик", async ({ page }) => { + const calls = await setup(page), editor = await open(page) + await editor.getByLabel("Существующий мост").selectOption("*1") + await editor.getByLabel("PVID моста", { exact: true }).fill("40") + await editor.getByRole("group", { name: "*11", exact: true }).getByRole("button", { name: "Удалить порт из плана" }).click() + await editor.getByLabel("Существующий мост").selectOption("*2") + await expect(editor.getByLabel("PVID моста", { exact: true })).toHaveValue("99") + await expect(editor.getByRole("group", { name: "*10", exact: true })).toHaveCount(0) + await expect(editor.getByText("Явное удаление портов:", { exact: false })).toHaveCount(0) + await editor.getByLabel("Существующий мост").selectOption("*1") + await expect(editor.getByLabel("PVID моста", { exact: true })).toHaveValue("1") + await expect(editor.getByRole("group", { name: "*11", exact: true })).toBeVisible() + const reads = () => calls.filter(call => call.path.endsWith("/bridge-vlan/configuration")).length + const before = reads() + await editor.getByLabel("PVID моста", { exact: true }).fill("40") + await editor.getByRole("button", { name: "Перечитать и сбросить черновик" }).click() + await expect.poll(reads).toBe(before + 1) + await expect(editor.getByLabel("PVID моста", { exact: true })).toHaveValue("1") + expect(calls.filter(call => call.method === "POST")).toHaveLength(0) +}) + +test("связанный VLAN: недостоверное состояние и отказ scope видны без отправки плана", async ({ page }) => { + const calls = await setup(page, async (route, server) => { + if (server === "2") return route.fulfill({ status: 403, json: { error: "Сервер вне scope подключения" } }) + const state = fixture(); delete (state.bridges[0] as Record)["vlan-filtering"] + await route.fulfill({ json: { state } }) + }) + const editor = await open(page) + await editor.getByLabel("Существующий мост").selectOption("*1") + await expect(editor.getByRole("alert")).toContainText("Не подтверждено поле vlan-filtering") + await expect(editor.getByRole("button", { name: "Подготовить связанный план VLAN" })).toHaveCount(0) + await page.getByLabel("Сервер интерфейсов").selectOption("2") + await expect(editor.getByRole("alert")).toContainText("Сервер вне scope подключения") + await expect(editor.getByLabel("Существующий мост")).toBeDisabled() + expect(calls.filter(call => call.method === "POST")).toHaveLength(0) +}) + +test("связанный VLAN: отменённый ответ прежнего сервера не заменяет новый снимок", async ({ page }) => { + let release!: () => void, started!: () => void, finished!: () => void + const pending = new Promise(resolve => { release = resolve }) + const requested = new Promise(resolve => { started = resolve }) + const settled = new Promise(resolve => { finished = resolve }) + await setup(page, async (route, server) => { + const state = fixture() + if (server === "1") { + started(); await pending + state.bridges[0].name = "late-old-bridge" + try { await route.fulfill({ json: { state } }) } catch { /* Aborted by scope change. */ } finally { finished() } + } else await route.fulfill({ json: { state } }) + }) + await page.goto("/mcp?tab=devices") + await page.getByLabel("Подключение для управления интерфейсами").selectOption(connectionId) + await page.getByLabel("Сервер интерфейсов").selectOption("1") + await page.getByLabel("Тип объектов устройства").selectOption("bridge") + await requested + await page.getByLabel("Сервер интерфейсов").selectOption("2") + const editor = page.getByRole("region", { name: "Связанная настройка Bridge VLAN" }) + await expect(editor.getByLabel("Существующий мост")).toBeEnabled() + await editor.getByLabel("Существующий мост").selectOption("*1") + await editor.getByLabel("PVID моста", { exact: true }).fill("70") + release(); await settled + await expect(editor.getByLabel("PVID моста", { exact: true })).toHaveValue("70") + await expect(editor.getByRole("option", { name: "late-old-bridge", exact: false })).toHaveCount(0) +}) diff --git a/tests/e2e/bridge-vlan/plan-review/mcp-context.spec.ts b/tests/e2e/bridge-vlan/plan-review/mcp-context.spec.ts new file mode 100644 index 00000000..cc619c5a --- /dev/null +++ b/tests/e2e/bridge-vlan/plan-review/mcp-context.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "@playwright/test" +import sdkPlan from "../sdk-public-plan.fixture.json" + +// Exact public SDK plan from tmp/mcp-bridge-vlan-sdk-20261105/result.json. +// The records endpoint uses publicPlan(): planId becomes id and row metadata is +// added; changes, their sequence, warnings, snapshots and expiry stay untouched. +// This is a browser/mock-API acceptance check, not native RouterOS forwarding. +const connectionId = "fa58fa40-4bcb-4616-9316-cb69463b3150" +const operationId = "690212c5-6e20-423e-b0d9-9791aeb8dbe7" +const action = "network.bridge-vlan.configure" + +for (const outcome of ["applied", "unknown"] as const) { + test(`linked VLAN: exact SDK review, human approval, apply and ${outcome} operation`, async ({ page }, testInfo) => { + await page.clock.setFixedTime(new Date("2026-11-05T10:42:00.000Z")) + const posts: { path: string; body: unknown }[] = [] + const unexpected: string[] = [] + let state = sdkPlan.state, applied = false + const record = () => ({ id: sdkPlan.planId, connectionId, operationId: action, + state, expiresAt: sdkPlan.expiresAt, createdAt: "2026-11-05T10:41:31.883Z", + approvedBy: state === "awaiting-approval" ? null : "human-reviewer", + changes: sdkPlan.changes }) + // Operations list returns the redacted SQL row, not mm_operation_get's steps. + const result = { id: operationId, plan_id: sdkPlan.planId, connection_id: connectionId, + state: outcome, verification: outcome === "applied" ? "passed" : "inconclusive", + error_code: outcome === "applied" ? null : "RESULT_UNKNOWN", + result: outcome === "applied" ? { confirmedWrites: 3, verificationScope: "configuration" } : { confirmedWrites: 0, needsReview: true }, + created_at: "2026-11-05T10:42:00.000Z", finished_at: "2026-11-05T10:42:01.000Z", + artifactReceipts: [] } + await page.route("**/api/**", async route => { + const request = route.request(), path = new URL(request.url()).pathname + const json = (body: unknown) => route.fulfill({ json: body }) + if (request.method() === "POST") { + posts.push({ path, body: request.postDataJSON() }) + if (path === `/api/mcp/plans/${sdkPlan.planId}/decision`) { + expect(state).toBe("awaiting-approval") + expect(request.postDataJSON()).toEqual({ approve: true }) + state = "approved" + return json({ ok: true }) + } + if (path === `/api/mcp/plans/${sdkPlan.planId}/apply`) { + expect(state).toBe("approved") + expect(request.postDataJSON()).toEqual({}) + expect(applied).toBe(false) + applied = true; state = "completed" + return json({ id: operationId, state: "pending" }) + } + unexpected.push(`${request.method()} ${path}`) + return route.fulfill({ status: 400, json: { error: "Unexpected mutation" } }) + } + if (path === "/api/auth/config") return json({ required: false }) + if (path === "/api/mcp/catalog") return json({ stage: "runtime", executionAvailable: true, operations: [] }) + if (path === "/api/mcp/plans") return json({ items: [record()], total: 1, nextCursor: null }) + if (path === "/api/mcp/operations") return json({ items: applied ? [result] : [], total: applied ? 1 : 0, nextCursor: null }) + if (path === "/api/mcp/connections") return json({ connections: [] }) + if (path === "/api/servers") return json([]) + if (path.startsWith("/api/mcp/")) return json({ items: [], total: 0, nextCursor: null }) + return json({}) + }) + await page.goto("/mcp?tab=plans") + const plans = page.locator("[data-slot=frame]").filter({ has: page.getByText("Планы изменений", { exact: true }) }) + const operations = page.locator("[data-slot=frame]").filter({ has: page.getByText("Выполнение операций", { exact: true }) }) + await plans.getByRole("button", { name: "Открыть", exact: true }).click() + const dialog = page.getByRole("dialog", { name: "Детали плана" }) + await expect(dialog.getByText("Шагов: 3", { exact: true })).toBeVisible() + await expect(dialog.getByText("Серверы: 1", { exact: true })).toBeVisible() + await expect(dialog.getByText("Сервер 1 · ipv4_address · *20", { exact: true })).toBeVisible() + for (const warning of sdkPlan.changes.warnings) await expect(dialog.getByText(warning, { exact: true })).toBeVisible() + const steps = dialog.locator("ol > li") + await expect(steps).toHaveCount(3) + for (const [index, value] of sdkPlan.changes.steps.entries()) { + await expect(steps.nth(index).locator("p")).toHaveText(`${index + 1}. ${value.step.action} · ${value.step.resource}`) + expect(JSON.parse(await steps.nth(index).locator("pre").innerText())).toEqual(value.step) + } + const snapshots = dialog.getByRole("heading", { name: "Исходное состояние", exact: true }).locator("..").locator("pre") + expect(JSON.parse(await snapshots.innerText())).toEqual(sdkPlan.changes.snapshots) + await dialog.getByText("Полные данные плана", { exact: true }).click() + expect(JSON.parse(await dialog.locator("details pre").innerText())).toEqual(record()) + expect(posts).toEqual([]) + await expect(dialog.getByRole("button", { name: "Применить план", exact: true })).toHaveCount(0) + await dialog.screenshot({ path: testInfo.outputPath("linked-vlan-review.png") }) + await dialog.getByRole("button", { name: "Подтвердить план", exact: true }).click() + await expect(dialog).not.toBeVisible() + await expect(plans.getByText("approved", { exact: true })).toBeVisible() + expect(posts).toEqual([{ path: `/api/mcp/plans/${sdkPlan.planId}/decision`, body: { approve: true } }]) + await plans.getByRole("button", { name: "Открыть", exact: true }).click() + await expect(dialog.getByRole("button", { name: "Подтвердить план", exact: true })).toHaveCount(0) + await expect(dialog.getByText("approved", { exact: true })).toBeVisible() + await dialog.getByText("Полные данные плана", { exact: true }).click() + expect(JSON.parse(await dialog.locator("details pre").innerText())).toEqual(record()) + const applyResponse = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/mcp/plans/${sdkPlan.planId}/apply`) + await dialog.getByRole("button", { name: "Применить план", exact: true }).click() + expect(await (await applyResponse).json()).toEqual({ id: operationId, state: "pending" }) + await expect(dialog).not.toBeVisible() + await expect(plans.getByText("completed", { exact: true })).toBeVisible() + await expect(operations.getByText(outcome, { exact: true })).toBeVisible() + expect(posts).toEqual([ + { path: `/api/mcp/plans/${sdkPlan.planId}/decision`, body: { approve: true } }, + { path: `/api/mcp/plans/${sdkPlan.planId}/apply`, body: {} }, + ]) + await operations.getByRole("button", { name: "Открыть", exact: true }).click() + const operationDialog = page.getByRole("dialog", { name: "Детали операции" }) + expect(JSON.parse(await operationDialog.locator("pre").innerText())).toEqual(result) + await operationDialog.screenshot({ path: testInfo.outputPath(`linked-vlan-operation-${outcome}.png`) }) + await page.keyboard.press("Escape") + await operations.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(operations.getByText(outcome, { exact: true })).toBeVisible() + expect(posts).toHaveLength(2) + expect(unexpected).toEqual([]) + await testInfo.attach("mock-api-evidence", { body: JSON.stringify({ source: "external SDK public plan, synthetic router", planId: sdkPlan.planId, requestSequence: posts, operation: result, nativeForwardingVerified: false }, null, 2), contentType: "application/json" }) + }) +} diff --git a/tests/e2e/bridge-vlan/sdk-public-plan.fixture.json b/tests/e2e/bridge-vlan/sdk-public-plan.fixture.json new file mode 100644 index 00000000..b2655d6d --- /dev/null +++ b/tests/e2e/bridge-vlan/sdk-public-plan.fixture.json @@ -0,0 +1,261 @@ +{ + "planId": "55205b80-071d-4037-b035-0136a1d02abe", + "expiresAt": "2026-11-05T10:56:31.883Z", + "state": "awaiting-approval", + "decision": { + "decision": "approval-required", + "reasons": [ + { + "code": "APPROVAL_REQUIRED", + "message": "Защищённый объект требует подтверждения человеком" + }, + { + "code": "APPROVAL_REQUIRED", + "message": "Опасная операция не разрешена для автономного выполнения" + } + ], + "limits": { + "callsPerMinute": 120, + "concurrentReads": 4, + "mutationsPerHour": 100, + "serversPerPlan": 5, + "objectsPerPlan": 100 + } + }, + "changes": { + "target": { + "scope": "servers", + "serverIds": [ + 1 + ] + }, + "objects": [ + { + "kind": "bridge", + "id": "*1", + "serverId": 1 + }, + { + "kind": "bridge_port", + "id": "*10", + "serverId": 1 + }, + { + "kind": "bridge_vlan", + "id": "*11", + "serverId": 1 + }, + { + "kind": "ethernet", + "id": "*2", + "serverId": 1 + }, + { + "kind": "ethernet", + "id": "*3", + "serverId": 1 + }, + { + "kind": "ipv4_address", + "id": "*20", + "serverId": 1 + }, + { + "kind": "ethernet", + "id": "*4", + "serverId": 1 + } + ], + "snapshots": [ + { + "serverId": 1, + "resource": "ethernet", + "rows": [ + { + ".id": "*2", + "name": "ether2", + "disabled": "false" + }, + { + ".id": "*3", + "name": "ether3", + "disabled": "false" + }, + { + ".id": "*4", + "name": "ether-mgmt", + "disabled": "false" + } + ], + "fingerprint": "08c56d03417d39672c01875e949f888bff078a2a3c3219d10462372aff685fae", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "bridge", + "rows": [ + { + ".id": "*1", + "name": "br-users", + "pvid": "1", + "vlan-filtering": "false", + "frame-types": "admit-all", + "ingress-filtering": "true", + "mvrp": "false", + "disabled": "false" + } + ], + "fingerprint": "b6cd139e68a4025879f73982c03458a0f3c1ef7b7a662f87b8421317b86ab5d6", + "projectionVersion": 4, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "vlan", + "rows": [], + "fingerprint": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "gre", + "rows": [], + "fingerprint": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "wireguard", + "rows": [], + "fingerprint": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "vxlan", + "rows": [], + "fingerprint": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "bridge_port", + "rows": [ + { + ".id": "*10", + "bridge": "br-users", + "interface": "ether2", + "pvid": "10", + "ingress-filtering": "true", + "frame-types": "admit-all", + "disabled": "false", + "comment": "" + } + ], + "fingerprint": "c7e426b6e866bb95030738fef6a244ba1f4ea51bef1b465adb7bb0b524fcae80", + "projectionVersion": 4, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "bridge_vlan", + "rows": [ + { + ".id": "*11", + "bridge": "br-users", + "vlan-ids": "10", + "tagged": "br-users", + "untagged": "ether2", + "disabled": "false", + "dynamic": "false", + "comment": "" + } + ], + "fingerprint": "fec705be87dbc8a3dab3aeb74b0127007d55759eea7dd2b4fc8ecc7f4757b5f6", + "projectionVersion": 4, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "ipv4_address", + "rows": [ + { + ".id": "*20", + "interface": "ether-mgmt", + "address": "127.0.0.1/8", + "disabled": "false", + "dynamic": "false" + } + ], + "fingerprint": "f9c4d5c98f751e7cb3fd9663f57c7731d92a53c096e51a90fd63ed29e314964b", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + }, + { + "serverId": 1, + "resource": "ipv6_address", + "rows": [], + "fingerprint": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "projectionVersion": 3, + "sourceProof": "e0718c0c34d3799be2f02d129907b9043d4d7de70b24c88a6c65161726befe38" + } + ], + "steps": [ + { + "kind": "ros", + "step": { + "serverId": 1, + "resource": "bridge_vlan", + "action": "create", + "fields": { + "bridge": "br-users", + "vlan-ids": "20", + "tagged": "br-users,ether3", + "disabled": "false" + } + } + }, + { + "kind": "ros", + "step": { + "serverId": 1, + "resource": "bridge_port", + "action": "create", + "fields": { + "bridge": "br-users", + "interface": "ether3", + "pvid": "1", + "ingress-filtering": "true", + "frame-types": "admit-only-vlan-tagged", + "disabled": "false" + } + } + }, + { + "kind": "ros", + "step": { + "serverId": 1, + "resource": "bridge", + "action": "update", + "id": "*1", + "fields": { + "vlan-filtering": "true" + } + } + } + ], + "requiresHuman": true, + "validUntil": "2026-11-05T11:11:31.385Z", + "warnings": [ + "Связанный план изменяет VLAN memberships и PVID в указанном порядке; включение filtering выполняется последним.", + "Каждая подтверждённая запись проверяется до следующей. При неизвестном результате дальнейшие записи останавливаются.", + "Используется основной адрес управления; независимость канала не подтверждена. Обязательно подтверждение человеком; каждый префикс плана сохраняет подтверждённый management-доступ." + ] + }, + "executionAvailable": true +} diff --git a/tests/e2e/fixtures/mcp-test-fonts.cjs b/tests/e2e/fixtures/mcp-test-fonts.cjs new file mode 100644 index 00000000..bc5f1880 --- /dev/null +++ b/tests/e2e/fixtures/mcp-test-fonts.cjs @@ -0,0 +1,8 @@ +// The auth scenarios check access controls, so their Next server can render +// with local fonts without reaching Google Fonts during compilation. +module.exports = { + 'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap': + "@font-face { font-family: 'Geist'; font-style: normal; font-weight: 100 900; src: local('Arial'); }", + 'https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap': + "@font-face { font-family: 'Geist Mono'; font-style: normal; font-weight: 100 900; src: local('Consolas'); }", +} diff --git a/tests/e2e/mcp-auth-context.spec.ts b/tests/e2e/mcp-auth-context.spec.ts index 78100aba..c575442a 100644 --- a/tests/e2e/mcp-auth-context.spec.ts +++ b/tests/e2e/mcp-auth-context.spec.ts @@ -6,10 +6,15 @@ const id = 'fa58fa40-4bcb-4616-9316-cb69463b3150' const at = '2026-10-29T00:00:00Z' const read = { id: 'logs.query', toolName: 'mm_logs_query', title: 'Поиск логов', section: 'logs', version: 1, effect: 'read', risk: 'ordinary', scope: 'servers', requiredPermissions: ['mm:servers:read'], implementation: 'available', annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } } const forbidden = { ...read, id: 'users.delete', title: 'Запрещённая операция каталога', requiredPermissions: ['mm:users:admin'], effect: 'write' } -async function setup(page: Page, permissions: string[]) { +async function setup(page: Page, permissions: string[], access: { expiresAt?: string; ownerValidUntil?: string; status?: string; generationValid?: boolean } = {}) { await page.addInitScript(permissions => { localStorage.setItem('mmapp_token', 'header.' + btoa(JSON.stringify({ sub: 'synthetic-owner', apps: ['mm'], permissions, exp: 4102444800 })) + '.signature') }, permissions) const connection = { id, name: 'Синтетический AI клиент', ownerId: 'synthetic-owner', policy: { id, version: 1, status: 'active', mode: 'observer', expiresAt: '2099-01-01T00:00:00Z', permissions: ['mm:servers:read'], scope: { serverIds: [1], operationIds: ['logs.query'], allowGlobal: false }, dangerousPolicy: 'approval', autonomousDangerousOperationIds: [], limits: { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 } }, createdAt: at, updatedAt: at, lastUsedAt: null, generationValid: true } const mutations: { path: string, body: Record }[] = [] + connection.policy.expiresAt = access.expiresAt ?? connection.policy.expiresAt + connection.policy.status = access.status ?? connection.policy.status + connection.generationValid = access.generationValid ?? connection.generationValid + const ownerValidUntil = access.ownerValidUntil ?? '2099-01-01T00:00:00Z' + let connectionReads = 0 let enabled = true await page.route('**/api/**', async route => { const request = route.request(), path = new URL(request.url()).pathname, method = request.method() @@ -20,7 +25,7 @@ async function setup(page: Page, permissions: string[]) { if (path === '/api/auth/config') return json({ required: true }) if (path === '/api/mcp/catalog') return json({ stage: 'runtime', executionAvailable: true, operations: [read, forbidden] }) if (path === '/api/mcp/overview') return json({ connections: 1, calls: 3, errors: 1, approvals: 0, running: 0, enabled, observedAt: at }) - if (path === '/api/mcp/connections') return json(method === 'POST' ? { connection, token: 'synthetic-one-time-token' } : { connections: [connection] }) + if (path === '/api/mcp/connections') { connectionReads++; const visible = { ...connection, ownerValidUntil }; return json(method === 'POST' ? { connection: visible, token: 'synthetic-one-time-token' } : { connections: [visible] }) } if (path === `/api/mcp/connections/${id}/status`) { connection.policy.status = request.postDataJSON().status; return json({ ok: true }) } if (path === '/api/mcp/stop-automation') { enabled = false; return json({ ok: true }) } if (path === '/api/mcp/requests') return json({ items: [{ id: 'synthetic-call', tool: 'mm_logs_query', created_at: at, connection_id: id, status: connection.policy.status === 'active' ? 'completed' : 'denied', error_code: connection.policy.status === 'active' ? null : `connection_${connection.policy.status}`, detail: 'synthetic fixture outcome' }], total: 1, nextCursor: null }) @@ -28,9 +33,51 @@ async function setup(page: Page, permissions: string[]) { if (path === '/api/servers') return json([]) return json({ items: [], total: 0, nextCursor: null }) }) - return { mutations } + return { mutations, get connectionReads() { return connectionReads } } } +for (const [access, label, reason] of [ + [{ expiresAt: '2000-01-01T00:00:00Z' }, 'Срок доступа истёк', 'Продлите срок в разрешениях подключения'], + [{ ownerValidUntil: '2000-01-01T00:00:00Z' }, 'Права владельца истекли', 'Владельцу нужно подтвердить актуальные права'], + [{ status: 'paused' }, 'На паузе', 'Новые вызовы подключения остановлены'], + [{ status: 'revoked' }, 'Доступ отозван', 'Подключение и его токены отозваны'], + [{ generationValid: false }, 'Доступ аннулирован', 'Поколение доступа больше не действительно'], +] as const) test(`synthetic: connection state explains ${label}`, async ({ page }, testInfo) => { + await setup(page, ['mm:mcp:read'], access) + await page.goto('/mcp?tab=connections') + const row = page.getByRole('row').filter({ hasText: 'Синтетический AI клиент' }) + await expect(row.getByText(label, { exact: true })).toBeVisible() + await expect(row.getByText(reason, { exact: true })).toBeVisible() + await expect(row.getByText('Активен', { exact: true })).toHaveCount(0) + await expect(page.getByRole('columnheader', { name: 'Доступ до', exact: true })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Права владельца до', exact: true })).toBeVisible() + if (label === 'Срок доступа истёк') { + await row.screenshot({ path: testInfo.outputPath('connection-expired-desktop.png') }) + await page.setViewportSize({ width: 390, height: 844 }) + await row.getByText(label, { exact: true }).scrollIntoViewIfNeeded() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('connection-expired-mobile.png') }) + } +}) + +for (const field of ['expiresAt', 'ownerValidUntil'] as const) test(`synthetic: connection clock crosses ${field} without another API response`, async ({ page }) => { + const start = new Date('2030-01-01T00:00:00Z') + await page.clock.install({ time: start }) + const state = await setup(page, ['mm:mcp:read'], { [field]: '2030-01-01T01:00:02Z' }) + await page.goto('/mcp?tab=connections') + const row = page.getByRole('row').filter({ hasText: 'Синтетический AI клиент' }) + await expect(row.getByText('Активен', { exact: true })).toBeVisible() + await page.clock.pauseAt(new Date('2030-01-01T01:00:00Z')) + // Ignore any catch-up polling from pauseAt; the crossing below is <5 seconds. + await page.waitForLoadState('networkidle') + const reads = state.connectionReads + await page.clock.runFor(2100) + await expect(row.getByText(field === 'expiresAt' ? 'Срок доступа истёк' : 'Права владельца истекли', { exact: true })).toBeVisible() + await expect(row.getByText('Активен', { exact: true })).toHaveCount(0) + expect(state.connectionReads).toBe(reads) + expect(state.mutations).toEqual([]) +}) + test('synthetic: bind OAuth consent to the displayed request and submit only once', async ({ page }) => { await setup(page, ['mm:mcp:read']) const firstId = 'fa58fa40-4bcb-4616-9316-cb69463b3151' @@ -90,6 +137,56 @@ test('synthetic: bind OAuth consent to the displayed request and submit only onc expect(decisions).toHaveLength(1) }) +test('synthetic: deny OAuth consent without an available connection', async ({ page, baseURL }) => { + await setup(page, ['mm:mcp:read']) + const requestId = 'fa58fa40-4bcb-4616-9316-cb69463b3151' + const callback = new URL('/local-oauth-callback', baseURL!) + const response = { id: requestId, name: 'OAuth without connection', redirect_uri: callback.href, scope: 'mcp' } + callback.searchParams.set('error', 'access_denied') + const headers = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'Authorization,Content-Type' } + await page.route('**/api/mcp/connections', route => route.fulfill({ headers, json: { connections: [] } })) + await page.route(`**/api/mcp/oauth/requests/${requestId}`, route => route.fulfill({ headers, json: response })) + const decisions: unknown[] = [] + await page.route('**/api/mcp/oauth/consent', async route => { + if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers }) + decisions.push(route.request().postDataJSON()) + await route.fulfill({ headers, json: { redirectUrl: callback.href } }) + }) + await page.route(callback.href, route => route.fulfill({ body: 'OAuth request declined' })) + + await page.goto('/mcp?oauthRequest=' + requestId) + await expect(page.getByText('OAuth without connection запрашивает доступ к MikrotikManager.')).toBeVisible() + await expect(page.getByLabel('Подключение', { exact: true }).locator('option')).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Разрешить', exact: true })).toBeDisabled() + await page.getByRole('button', { name: 'Отклонить', exact: true }).click() + await expect(page).toHaveURL(callback.href) + expect(decisions).toEqual([{ requestId, approve: false }]) +}) + +test('synthetic: OAuth approval becomes unavailable when the selected connection expires', async ({ page }) => { + const start = new Date('2030-01-01T00:00:00Z') + await page.clock.install({ time: start }) + await page.clock.pauseAt(new Date('2030-01-01T00:00:01Z')) + const state = await setup(page, ['mm:mcp:read'], { expiresAt: '2030-01-01T00:00:03Z' }) + const requestId = 'fa58fa40-4bcb-4616-9316-cb69463b3151' + const headers = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,OPTIONS', 'access-control-allow-headers': 'Authorization,Content-Type' } + await page.route(`**/api/mcp/oauth/requests/${requestId}`, route => route.request().method() === 'OPTIONS' + ? route.fulfill({ status: 204, headers }) + : route.fulfill({ headers, json: { id: requestId, name: 'OAuth expiry', redirect_uri: 'https://a.example.test/callback', scope: 'mcp' } })) + await page.goto('/mcp?oauthRequest=' + requestId) + const connection = page.getByLabel('Подключение', { exact: true }) + const approve = page.getByRole('button', { name: 'Разрешить', exact: true }) + await connection.selectOption(id) + await expect(approve).toBeEnabled() + const reads = state.connectionReads + await page.clock.runFor(2100) + await expect(approve).toBeDisabled() + await expect(connection.locator('option')).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Отклонить', exact: true })).toBeEnabled() + expect(state.connectionReads).toBe(reads) + expect(state.mutations).toEqual([]) +}) + test('synthetic: issue only selected server/read permission and hide one-time token', async ({ page }) => { const state = await setup(page, ['mm:mcp:admin', 'mm:servers:read']) await page.goto('/mcp?tab=connections') diff --git a/tests/e2e/mcp-context.spec.ts b/tests/e2e/mcp-context.spec.ts index 37b32ef6..35fbf8da 100644 --- a/tests/e2e/mcp-context.spec.ts +++ b/tests/e2e/mcp-context.spec.ts @@ -1,4 +1,6 @@ import { test, expect, type Page } from "@playwright/test" +import { evaluateMcpPolicy } from "../../backend/src/modules/mcp/policy" +import type { McpPolicyPreviewRequest } from "@mmapp/contracts/mcp" const id = "fa58fa40-4bcb-4616-9316-cb69463b3150" const at = "2026-10-17T00:00:00Z" const operation = { id: "logs.query", toolName: "mm_logs_query", title: "Поиск логов", section: "logs", version: 1, effect: "read", risk: "ordinary", scope: "servers", requiredPermissions: ["mm:servers:read"], implementation: "available", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } } @@ -20,7 +22,7 @@ async function setup(page: Page, fail = false) { if (path === "/api/mcp/devices/catalog") return json({ servers: [{ id: 1, name: "CHR lab", host: "192.0.2.1", enabled: true }], resources: [{ id: "bridge", title: "Bridge", read: true, create: true, update: true, delete: true, fields: [{ key: "name", type: "string", requiredOnCreate: true }, { key: "comment", type: "string", requiredOnCreate: false }, { key: "disabled", type: "boolean", requiredOnCreate: false }] }] }) if (path === "/api/mcp/devices/1/bridge/plan") return json({ planId: id, state: "awaiting-approval" }) if (path === "/api/mcp/devices/1/bridge") return json({ items: [{ ".id": "*A", name: "lab-bridge", comment: "baseline", disabled: "true" }], total: 1, nextOffset: null }) - if (path.endsWith("/check")) return json({ ok: true, checks: { enabled: true, active: true, protocolHandshake: true }, checkedAt: at, protocolProbe: { status: "passed", elapsedMs: 12, credentialType: "temporary-no-operations", clientCredentialsVerified: false } }) + if (path.endsWith("/check")) return json({ ok: true, checks: { enabled: true, active: true, protocolHandshake: true }, checkedAt: at, protocolProbe: { status: "passed", elapsedMs: 12, credentialType: "temporary-capabilities-read", capabilitiesRead: "passed", clientCredentialsVerified: false } }) if (path.endsWith("/grants")) return json({ grants: [] }) if (path === "/api/mcp/routing-profiles/plan") return json({ planId: id, state: "awaiting-approval" }) if (path === "/api/mcp/routing-profiles") return json({ profiles: [{ id, connection_id: id, config: { name: "Community WAN", community: "65000:10", family: "ipv6", table: "main" }, state: { pausedReason: "Допуск контроллера истёк" }, enabled: false, grant_expires_at: at }] }) @@ -63,6 +65,80 @@ test("MCP приостанавливает опрос по сигналу скр await expect.poll(overviewCalls).toBeGreaterThan(before) }) +test("MCP: медленное чтение завершается без отмены очередным опросом", async ({ page }) => { + await setup(page) + let calls = 0 + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route("**/api/mcp/overview", async route => { + calls++ + await pending + await route.fulfill({ json: { connections: 1, calls: 7, errors: 1, approvals: 1, running: 0, enabled: true, observedAt: at } }) + }) + await page.clock.install() + await page.goto("/mcp?tab=overview") + try { + await expect.poll(() => calls).toBeGreaterThan(0) + const initialCalls = calls + await page.clock.runFor(11_000) + expect(calls).toBe(initialCalls) + } finally { + release() + } + await expect(page.getByText("MCP включён", { exact: true })).toBeVisible() + await page.clock.runFor(5_000) + await expect.poll(() => calls).toBeGreaterThan(1) +}) + +test("MCP: явное обновление заменяет медленное чтение и сохраняет свежий ответ", async ({ page }) => { + await setup(page) + let calls = 0 + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route("**/api/mcp/overview", async route => { + const current = ++calls + if (current === 1) await pending + await route.fulfill({ json: { connections: 1, calls: current === 1 ? 7 : 99, errors: 0, approvals: 0, running: 0, enabled: true, observedAt: at } }) + }) + await page.clock.install() + await page.goto("/mcp?tab=overview") + try { + await expect.poll(() => calls).toBe(1) + await page.evaluate(() => window.dispatchEvent(new Event("mcp-refresh"))) + await expect.poll(() => calls).toBe(2) + await expect(page.getByText("99", { exact: true })).toBeVisible() + } finally { + release() + } + await page.clock.runFor(1_000) + await expect(page.getByText("99", { exact: true })).toBeVisible() + await expect(page.getByText("7", { exact: true })).toHaveCount(0) +}) + +test("MCP: зависшее чтение показывает ошибку и повторяется после срока ожидания", async ({ page }) => { + await setup(page) + let calls = 0 + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route("**/api/mcp/overview", async route => { + if (++calls === 1) await pending + await route.fulfill({ json: { connections: 1, calls: 7, errors: 0, approvals: 0, running: 0, enabled: true, observedAt: at } }) + }) + await page.clock.install() + await page.goto("/mcp?tab=overview") + try { + await expect.poll(() => calls).toBe(1) + await page.clock.runFor(60_001) + await expect(page.getByRole("alert").filter({ hasText: "Обновление не завершилось за минуту" })).toBeVisible() + await page.clock.runFor(5_000) + await expect.poll(() => calls).toBeGreaterThan(1) + await expect(page.getByText("MCP включён", { exact: true })).toBeVisible() + await expect(page.getByRole("alert").filter({ hasText: "Обновление не завершилось за минуту" })).toHaveCount(0) + } finally { + release() + } +}) + test("MCP журнал: заполненная квота объясняет удержание и показывает admin counters",async({page})=>{ await setup(page) await page.route("**/api/mcp/overview",route=>route.fulfill({json:{connections:1,calls:7,errors:0,approvals:0,running:0,enabled:true,observedAt:at,pollStorageCapacity:{status:"observed",owner:{used:8,limit:8,remaining:0},global:{exhausted:true,usage:{used:128,limit:128,remaining:0}},retentionDays:14}}})) @@ -98,11 +174,15 @@ test("MCP журнал: на мобильном общая квота блоки expect(await capacity.evaluate(element=>element.scrollWidth<=element.clientWidth)).toBe(true) await capacity.screenshot({path:"test-results/mcp-poll-capacity-mobile.png"}) }) +const routePathCheckConfig = { + name: "Проверка назначения", serverId: 1, community: "65000:10", family: "ipv4", table: "main", probeVrf: "main", + candidates: [{ id: "probe", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { targetInterface: "exit", expectedHops: [{ serverId: 3, interface: "exit" }] } }], +} + test("Route AI: самостоятельная проба имеет отдельное назначение и не разрешает Community", async ({ page }) => { await setup(page) const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Authorization,Content-Type" } - const config = { name: "Проверка назначения", serverId: 1, community: "65000:10", family: "ipv4", table: "main", probeVrf: "main", candidates: [{ id: "probe", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { targetInterface: "exit", expectedHops: [{ serverId: 3, interface: "exit" }] } }] } - await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ headers, json: { profiles: [{ id, connection_id: id, config, enabled: false }] } })) + await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ headers, json: { profiles: [{ id, connection_id: id, config: routePathCheckConfig, enabled: false }] } })) await page.route(`**/api/mcp/routing-profiles/${id}/check`, route => { if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) return route.fulfill({ headers, json: { proofPurpose: route.request().postDataJSON().proofPurpose, status: "verified", returnPathVerified: false } }) @@ -122,6 +202,55 @@ test("Route AI: самостоятельная проба имеет отдел await expect(page.getByText("Результат не разрешает переключение Community", { exact: false })).toBeVisible() }) +test("Route AI: смена выбора отбрасывает запоздалый результат и ошибку пробы", async ({ page }) => { + await setup(page) + const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Authorization,Content-Type" } + const anotherId = "fa58fa40-4bcb-4616-9316-cb69463b3151" + const candidate = routePathCheckConfig.candidates[0] + const config = { ...routePathCheckConfig, candidates: [candidate, { ...candidate, id: "reserve", wanInterface: "wan-b", gateway: "192.0.2.2", gatewayRef: { resource: "ipv4_route", id: "*s" } }] } + await page.route("**/api/mcp/routing-profiles", route => route.fulfill({ headers, json: { profiles: [id, anotherId].map(profileId => ({ id: profileId, config, version: 1, enabled: false })) } })) + let requestCount = 0 + let release: () => void = () => {} + await page.route("**/api/mcp/routing-profiles/*/check", async route => { + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) + const request = ++requestCount + if (request < 3) await new Promise(resolve => { release = resolve }) + return request === 2 + ? route.fulfill({ status: 502, headers, json: { error: "Старая ошибка пробы" } }) + : route.fulfill({ headers, json: { marker: request === 1 ? "old-proof" : "current-proof", ...route.request().postDataJSON() } }) + }) + await page.goto("/mcp?tab=routing") + const profile = page.getByLabel("Профиль для проверки пути") + const path = page.getByLabel("Проверяемый путь") + const purpose = page.getByLabel("Назначение проверки") + const check = page.getByRole("button", { name: "Проверить контрольный путь", exact: true }) + const start = async (number: number) => { + await check.click() + await expect.poll(() => requestCount).toBe(number) + } + await profile.selectOption(id) + await path.selectOption("probe") + await start(1) + await path.selectOption("reserve") + await path.selectOption("probe") + release() + await expect(check).toBeEnabled() + await expect(page.getByText("old-proof", { exact: false })).toHaveCount(0) + + await start(2) + await profile.selectOption(anotherId) + await path.selectOption("reserve") + await purpose.selectOption("control-path") + release() + await expect(check).toBeEnabled() + await expect(page.getByText("Старая ошибка пробы", { exact: false })).toHaveCount(0) + + await start(3) + const result = page.locator("pre[role=status]").filter({ hasText: "current-proof" }) + await expect(result).toContainText('"candidateId": "reserve"') + await expect(result).toContainText('"proofPurpose": "control-path"') +}) + test("Route AI: автовосстановление блокирует активацию, но позволяет сохранить выключенный профиль", async ({ page }) => { await setup(page) const config = { name: "Профиль восстановления", serverId: 1, community: "65000:10", family: "ipv4", table: "main", probeVrf: "main", autoRecovery: true, candidates: [{ id: "probe", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*r" }, controlProbe: { targetInterface: "exit", expectedHops: [{ serverId: 3, interface: "exit" }] } }] } @@ -154,6 +283,7 @@ test("MCP: доступ, проверка подключения, выдача const readiness = page.locator("pre").filter({ hasText: "protocolProbe" }) await expect(readiness).toContainText('"protocolHandshake": true') await expect(readiness).toContainText('"clientCredentialsVerified": false') + await expect(page.getByRole("status").filter({ hasText: "Проверочное чтение возможностей прошло." })).toBeVisible() await page.getByRole("button", { name: "Выдать доступ", exact: true }).click() await page.getByLabel("Название", { exact: true }).fill("Новый AI клиент") await page.getByRole("button", { name: "Далее", exact: true }).click() @@ -170,6 +300,186 @@ test("MCP: доступ, проверка подключения, выдача await page.screenshot({ path: "test-results/mcp-route-ai.png", fullPage: true }) expect(errors).toEqual([]) }) +test("MCP: временные допуски и результаты проверки привязаны к подключению", async ({ page }) => { + await setup(page) + const otherId = "fa58fa40-4bcb-4616-9316-cb69463b3151" + const other = { ...connection, id: otherId, name: "Мониторинг LAN", policy: { ...connection.policy, id: otherId } } + let listed = [connection, other] + await page.route("**/api/mcp/connections", route => route.fulfill({ json: { connections: listed } })) + + let releaseOtherGrants!: () => void + let releaseIssue!: () => void + let releaseFirstCheck!: () => void + let releaseStaleCheck!: () => void + const otherGrants = new Promise(resolve => { releaseOtherGrants = resolve }) + const issue = new Promise(resolve => { releaseIssue = resolve }) + const firstCheck = new Promise(resolve => { releaseFirstCheck = resolve }) + const staleCheck = new Promise(resolve => { releaseStaleCheck = resolve }) + const grant = (id: string) => ({ id, grant: { expiresAt: at }, revoked_at: null }) + + await page.route(`**/api/mcp/connections/${id}/grants`, async route => { + if (route.request().method() === "GET") return route.fulfill({ json: { grants: [grant(id)] } }) + await issue + return route.fulfill({ json: { token: "issued-for-wan", expiresAt: at } }) + }) + await page.route(`**/api/mcp/connections/${otherId}/grants`, async route => { + await otherGrants + await route.fulfill({ json: { grants: [grant(otherId)] } }) + }) + await page.route("**/api/mcp/grants/*", route => route.fulfill({ json: { ok: true } })) + await page.route(`**/api/mcp/connections/${id}/check`, async route => { + await firstCheck + await route.fulfill({ json: { ok: true, checks: { wan: true }, checkedAt: at } }) + }) + let otherChecks = 0 + await page.route(`**/api/mcp/connections/${otherId}/check`, async route => { + const stale = ++otherChecks === 1 + if (stale) await staleCheck + await route.fulfill({ json: { ok: true, checks: stale ? { stale: true } : { lan: true }, checkedAt: at } }) + }) + + await page.goto("/mcp?tab=connections") + const selection = page.getByLabel("Подключение для проверки") + const revoke = page.getByRole("button", { name: "Отозвать допуск", exact: true }) + const check = page.getByRole("button", { name: "Проверить готовность доступа", exact: true }) + const create = page.getByRole("button", { name: "Выдать временный допуск", exact: true }) + const readiness = page.locator("pre") + + try { + await selection.selectOption(id) + await expect(revoke).toBeVisible() + await Promise.all([page.waitForRequest(`**/api/mcp/connections/${id}/check`), check.click()]) + await page.getByRole("checkbox", { name: "logs.query", exact: true }).check() + await Promise.all([ + page.waitForRequest(request => request.method() === "POST" && request.url().endsWith(`/connections/${id}/grants`)), + create.click(), + ]) + + await Promise.all([page.waitForRequest(`**/api/mcp/connections/${otherId}/grants`), selection.selectOption(otherId)]) + await expect(revoke).toHaveCount(0) + await page.getByRole("checkbox", { name: "logs.query", exact: true }).check() + await expect(create).toBeDisabled() + releaseOtherGrants() + await expect(revoke).toBeVisible() + const revoked = page.waitForRequest(request => request.method() === "DELETE" && request.url().includes("/api/mcp/grants/")) + await revoke.click() + expect(new URL((await revoked).url()).pathname).toBe(`/api/mcp/grants/${otherId}`) + + await Promise.all([page.waitForRequest(`**/api/mcp/connections/${otherId}/check`), check.click()]) + await check.click() + await expect(readiness).toContainText('"lan": true') + const lateResponses = Promise.all([ + page.waitForResponse(`**/api/mcp/connections/${id}/check`), + page.waitForResponse(`**/api/mcp/connections/${otherId}/check`), + ]) + releaseFirstCheck() + releaseStaleCheck() + for (const response of await lateResponses) await response.finished() + + listed = [other] + releaseIssue() + const dialog = page.getByRole("dialog") + await expect(dialog).toContainText(`Подключение «${connection.name}»`) + await expect(dialog.getByLabel("Одноразовый токен MCP")).toHaveValue("issued-for-wan") + await expect(selection.locator(`option[value="${id}"]`)).toHaveCount(0) + await dialog.getByRole("button", { name: "Закрыть и скрыть токен" }).click() + await expect(selection).toHaveValue(otherId) + await expect(readiness).toContainText('"lan": true') + await expect(readiness).not.toContainText('"wan": true') + await expect(readiness).not.toContainText('"stale": true') + await expect(create).toBeEnabled() + } finally { + releaseOtherGrants() + releaseIssue() + releaseFirstCheck() + releaseStaleCheck() + } +}) + +test("MCP: симулятор разрешений использует область операции из каталога", async ({ page }) => { + await setup(page) + const otherId = "fa58fa40-4bcb-4616-9316-cb69463b3158" + const capabilities = { ...operation, id: "capabilities.read", toolName: "mm_capabilities_get", title: "Доступные возможности", scope: "connection", requiredPermissions: [] } + const users = { ...operation, id: "users.list", toolName: "mm_users_list", title: "Каталог пользователей", scope: "global", requiredPermissions: ["mm:users:read"] } + const ownerPermissions = ["mm:servers:read", "mm:users:read"] + const policy = { ...connection.policy, permissions: ownerPermissions, scope: { serverIds: [1, 2], operationIds: [capabilities.id, users.id, operation.id], allowGlobal: true } } as McpPolicyPreviewRequest["policy"] + const foreign = { ...connection, ownerId: "other-owner", policy } + const other = { ...foreign, id: otherId, name: "Другой доступ", policy: { ...policy, id: otherId } } + const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Authorization,Content-Type" } + await page.route("**/api/mcp/catalog", route => route.fulfill({ headers, json: { stage: "runtime", executionAvailable: true, operations: [operation, capabilities, users] } })) + await page.route("**/api/mcp/connections", route => route.fulfill({ headers, json: { connections: [foreign, other] } })) + const previews: unknown[] = [], protections: unknown[] = [] + let releaseStale!: () => void + const stale = new Promise(resolve => { releaseStale = resolve }) + await page.route("**/api/mcp/policy/preview", async route => { + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) + const input = route.request().postDataJSON() as Pick + previews.push(input) + if (input.target.scope === "servers" && input.target.serverIds.includes(3)) await stale + const result = evaluateMcpPolicy({ operationId: input.operationId, target: input.target, policy, ownerPermissions, objects: [], protections: [], objectCount: 0, usage: { callsInMinute: 0, concurrentReads: 0, mutationsInHour: 0 }, auditAvailable: true }) + await route.fulfill({ headers, json: { simulation: true, executionAvailable: false, evaluatedAt: at, policyVersion: 1, result } }) + }) + await page.route("**/api/mcp/protections", route => { + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) + if (route.request().method() === "POST") { protections.push(route.request().postDataJSON()); return route.fulfill({ headers, json: { id } }) } + return route.fulfill({ headers, json: { protections: [] } }) + }) + + try { + await page.goto("/mcp?tab=permissions") + const selectConnection = page.getByLabel("Подключение", { exact: true }) + const selectOperation = page.getByLabel("Операция", { exact: true }) + const serverIds = page.getByLabel("ID серверов", { exact: true }) + const preview = page.getByRole("button", { name: "Проверить", exact: true }) + const allowed = page.getByText("Разрешено", { exact: true }) + await selectConnection.selectOption(id) + await selectOperation.selectOption(capabilities.id) + await expect(page.getByText("Область операции — подключение")).toBeVisible() + await expect(serverIds).toHaveCount(0) + await preview.click() + await expect(allowed).toBeVisible() + expect(previews[0]).toEqual({ connectionId: id, operationId: capabilities.id, target: { scope: "connection" } }) + + await selectOperation.selectOption(operation.id) + await expect(allowed).toHaveCount(0) + await expect(preview).toBeDisabled() + await serverIds.fill("1,1") + await expect(preview).toBeDisabled() + await serverIds.fill("2, 1") + await preview.click() + await expect(allowed).toBeVisible() + expect(previews[1]).toEqual({ connectionId: id, operationId: operation.id, target: { scope: "servers", serverIds: [2, 1] } }) + + await serverIds.fill("1, 3") + await expect(allowed).toHaveCount(0) + await preview.click() + await expect.poll(() => previews.length).toBe(3) + await selectOperation.selectOption(users.id) + await expect(page.getByText("Область операции — всё приложение")).toBeVisible() + await expect(serverIds).toHaveCount(0) + await preview.click() + await expect(allowed).toBeVisible() + expect(previews[3]).toEqual({ connectionId: id, operationId: users.id, target: { scope: "global" } }) + const late = page.waitForResponse(response => response.url().endsWith("/api/mcp/policy/preview") && response.request().postDataJSON().target.scope === "servers") + releaseStale() + await (await late).finished() + await expect(allowed).toBeVisible() + await expect(page.getByText("Запрещено", { exact: true })).toHaveCount(0) + + await selectConnection.selectOption(otherId) + await expect(selectOperation).toHaveValue("") + await expect(allowed).toHaveCount(0) + await expect(preview).toBeDisabled() + + await page.getByLabel("Тип объекта", { exact: true }).fill("ethernet") + await page.getByLabel("Стабильный ID", { exact: true }).fill("*A") + const protect = page.getByRole("button", { name: "Добавить защиту" }) + await expect(protect).toBeDisabled() + await page.getByLabel("ID сервера защищаемого объекта").fill("9") + await protect.click() + await expect.poll(() => protections).toEqual([{ object: { kind: "ethernet", id: "*A", protection: "human-approval-required", serverId: 9 }, reason: "" }]) + } finally { releaseStale() } +}) test("MCP: ошибки остаются видимыми, узкий экран не переполняется", async ({ page }) => { await setup(page, true) await page.setViewportSize({ width: 390, height: 844 }) @@ -398,6 +708,63 @@ test("MCP: изменение интерфейса готовит план то await page.screenshot({ path: "test-results/mcp-device-editor.png", fullPage: true }) }) +test("MCP: можно добавить первый комментарий интерфейса без подстановки остальных свойств", async ({ page }) => { + await setup(page) + await page.route("**/api/mcp/devices/1/bridge?*", route => route.fulfill({ json: { + items: [{ ".id": "*A", name: "lab-bridge", disabled: "true" }], total: 1, nextOffset: null, + } })) + await page.goto("/mcp?tab=devices") + await page.getByLabel("Подключение для управления интерфейсами").selectOption(id) + await page.getByLabel("Сервер интерфейсов").selectOption("1") + await page.getByLabel("Тип объектов устройства").selectOption("bridge") + await page.getByText("lab-bridge", { exact: true }).click() + await page.getByRole("textbox", { name: "comment", exact: true }).fill("first comment") + const sent = page.waitForRequest(request => request.method() === "POST" && request.url().includes("/api/mcp/devices/1/bridge/plan")) + await page.getByRole("button", { name: "Подготовить план изменения", exact: true }).click() + expect((await sent).postDataJSON()).toEqual({ connectionId: id, action: "update", id: "*A", fields: { comment: "first comment" } }) + await expect(page.getByRole("status").filter({ hasText: "Создан план" })).toBeVisible() +}) + +test("MCP: Ethernet допускает явное изменение неизвестного disabled", async ({ page }) => { + await setup(page) + await page.route("**/api/mcp/devices/catalog?*", route => route.fulfill({ json: { + servers: [{ id: 1, name: "CHR lab", host: "192.0.2.1", enabled: true }], + resources: [{ id: "ethernet", title: "Ethernet", read: true, create: false, update: true, delete: false, fields: [ + { key: "name", type: "string", requiredOnCreate: false }, + { key: "disabled", type: "boolean", requiredOnCreate: false }, + { key: "auto-negotiation", type: "boolean", requiredOnCreate: false }, + ] }], + } })) + await page.route("**/api/mcp/devices/1/ethernet?*", route => route.fulfill({ json: { + items: [{ ".id": "*E", name: "lab-ethernet", running: "true" }], total: 1, nextOffset: null, + } })) + await page.route("**/api/mcp/devices/1/ethernet/plan", route => route.fulfill({ json: { planId: id, state: "awaiting-approval" } })) + await page.goto("/mcp?tab=devices") + await page.getByLabel("Подключение для управления интерфейсами").selectOption(id) + await page.getByLabel("Сервер интерфейсов").selectOption("1") + await page.getByLabel("Тип объектов устройства").selectOption("ethernet") + await page.getByText("lab-ethernet", { exact: true }).click() + + const disabled = page.getByRole("combobox", { name: "disabled", exact: true }) + const prepare = page.getByRole("button", { name: "Подготовить план изменения", exact: true }) + const warning = page.getByText("Исходное состояние неизвестно. Для этого изменения обратный план недоступен.") + await expect(disabled.locator("option:checked")).toHaveText("Текущее значение неизвестно") + await expect(disabled).toHaveValue("") + await expect(prepare).toBeDisabled() + await expect(page.getByRole("combobox", { name: "auto-negotiation", exact: true })).toHaveCount(0) + await disabled.selectOption("false") + await expect(prepare).toBeEnabled() + await expect(warning).toBeVisible() + await disabled.selectOption("") + await expect(prepare).toBeDisabled() + await expect(warning).toHaveCount(0) + await disabled.selectOption("true") + const sent = page.waitForRequest(request => request.method() === "POST" && request.url().endsWith("/api/mcp/devices/1/ethernet/plan")) + await prepare.click() + expect((await sent).postDataJSON()).toEqual({ connectionId: id, action: "update", id: "*E", fields: { disabled: true } }) + await expect(page.getByRole("status").filter({ hasText: "Создан план" })).toBeVisible() +}) + test("MCP: архив БД загружает человек, подготовка плана не запускает restore", async ({ page }) => { const calls = await setup(page) await page.route("**/api/mcp/connections", route => route.fulfill({ json: { connections: [{ ...connection, policy: { ...connection.policy, permissions: ["mm:settings:admin"], scope: { serverIds: [1], operationIds: ["database.restore"], allowGlobal: true } } }] } })) @@ -437,3 +804,64 @@ test("MCP: обзор плана показывает исходное сост await expect(dialog.getByRole("button", { name: "Подтвердить план", exact: true })).toBeVisible() await dialog.screenshot({ path: "test-results/mcp-plan-review.png" }) }) + +test("MCP: запоздалое решение не закрывает другой план и повторная отправка блокируется", async ({ page }) => { + await setup(page) + const secondId = "fa58fa40-4bcb-4616-9316-cb69463b3199" + const first = { id, operationId: "routing.community.switch", state: "awaiting-approval", createdAt: at, expiresAt: "2099-01-01T00:00:00Z", changes: {} } + const second = { ...first, id: secondId, operationId: "ros.ethernet.update", state: "approved" } + let finishDecision!: () => void + let finishApply!: () => void + const decisionGate = new Promise(resolve => { finishDecision = resolve }) + const applyGate = new Promise(resolve => { finishApply = resolve }) + const decisions: unknown[] = [] + const applications: unknown[] = [] + await page.route(url => url.pathname === "/api/mcp/plans", route => route.fulfill({ json: { items: [first, second], total: 2, nextCursor: null } })) + await page.route(`**/api/mcp/plans/${id}/decision`, async route => { + decisions.push(route.request().postDataJSON()) + await decisionGate + first.state = "approved" + await route.fulfill({ json: { ok: true } }) + }) + await page.route(`**/api/mcp/plans/${secondId}/apply`, async route => { + applications.push(route.request().postDataJSON()) + await applyGate + await route.fulfill({ json: { id: secondId, state: "pending" } }) + }) + try { + await page.goto("/mcp?tab=plans") + await page.getByText(first.operationId, { exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: "Подтвердить план", exact: true }).click() + await expect(dialog.getByRole("button", { name: "Подтвердить план", exact: true })).toBeDisabled() + await expect(dialog.getByRole("button", { name: "Отклонить", exact: true })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) + + await page.getByText(first.operationId, { exact: true }).click() + await expect(dialog.getByRole("button", { name: "Подтвердить план", exact: true })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) + + await page.getByText(second.operationId, { exact: true }).click() + const apply = dialog.getByRole("button", { name: "Применить план", exact: true }) + await expect(apply).toBeEnabled() + const decisionResponse = page.waitForResponse(response => response.url().endsWith(`/plans/${id}/decision`)) + const refreshed = page.waitForResponse(response => new URL(response.url()).pathname === "/api/mcp/plans" && response.json().then(payload => payload.items?.some((item: { id: string; state: string }) => item.id === id && item.state === "approved"))) + finishDecision() + await decisionResponse + await refreshed + await expect(apply).toBeEnabled() + expect(decisions).toEqual([{ approve: true }]) + + await apply.click() + await expect(apply).toBeDisabled() + await expect.poll(() => applications).toEqual([{}]) + finishApply() + await expect(dialog).toHaveCount(0) + expect(applications).toEqual([{}]) + } finally { + finishDecision() + finishApply() + } +}) diff --git a/tests/e2e/object-permissions/mcp-context.spec.ts b/tests/e2e/object-permissions/mcp-context.spec.ts new file mode 100644 index 00000000..6a46a276 --- /dev/null +++ b/tests/e2e/object-permissions/mcp-context.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test" +const id = "fa58fa40-4bcb-4616-9316-cb69463b3150" +const at = "2026-10-17T00:00:00Z" +const policy = { id, version: 1, status: "active", mode: "autonomous", expiresAt: "2099-01-01T00:00:00Z", permissions: ["mm:network:read", "mm:network:write"], scope: { serverIds: [1, 2], operationIds: ["ros.ethernet.list", "ros.ethernet.update"], allowGlobal: false }, dangerousPolicy: "approval", autonomousDangerousOperationIds: [], limits: { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 } } +const updateOperation = { id: "ros.ethernet.update", toolName: "mm_ethernet_update_plan", version: 1, title: "Изменить Ethernet", section: "network", effect: "write", risk: "dangerous", scope: "servers", requiredPermissions: ["mm:network:write"], implementation: "available", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true } } +test("объектная проверка и защита сохраняют server/resource/native ID и сбрасывают контекст", async ({ page }) => { + const previews: unknown[] = [], writes: { object: unknown; reason: string }[] = [] + const duplicateKeys: string[] = [] + page.on("console", message => { if (message.type() === "error" && message.text().includes("same key")) duplicateKeys.push(message.text()) }) + await page.addInitScript(() => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "ui-admin", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin"], exp: 4102444800 })) + ".signature")) + await page.route("**/api/**", async route => { + const path = new URL(route.request().url()).pathname, method = route.request().method() + const json = (body: unknown) => route.fulfill({ json: body }) + if (method === "OPTIONS") return route.fulfill({ status: 204 }) + if (path === "/api/auth/config") return json({ required: false }) + if (path === "/api/mcp/catalog") return json({ stage: "runtime", executionAvailable: true, operations: [updateOperation] }) + if (path === "/api/mcp/connections") return json({ connections: [{ id, name: "Owner", ownerId: "ui-admin", policy, createdAt: at, updatedAt: at, lastUsedAt: null, generationValid: true }] }) + if (path === "/api/mcp/devices/catalog") return json({ servers: [1, 2].map(id => ({ id, name: `CHR ${id}`, host: `192.0.2.${id}`, enabled: true })), resources: [{ id: "ethernet", title: "Ethernet", read: true, create: false, update: true, delete: false, fields: [] }] }) + if (/\/devices\/[12]\/ethernet$/.test(path)) return json({ items: [{ ".id": "*A", name: "Ethernet" }], total: 1, nextOffset: null }) + if (path === "/api/mcp/protections") { + if (method === "POST") { writes.push(route.request().postDataJSON()); return json({ id }) } + return json({ protections: [{ id, object_ref: { kind: "ethernet", id: "*A", serverId: 1, protection: "deny" }, reason: "Management" }] }) + } + if (path === "/api/mcp/policy/preview") { + previews.push(route.request().postDataJSON()) + return json({ simulation: true, executionAvailable: false, evaluatedAt: at, policyVersion: 1, result: { decision: "denied", reasons: [{ code: "PROTECTED_OBJECT", message: "Изменение интерфейса управления запрещено защитой" }], limits: policy.limits } }) + } + return json({ items: [], total: 0, nextCursor: null }) + }) + await page.goto("/mcp?tab=permissions") + await page.getByLabel("Подключение", { exact: true }).selectOption(id) + const object = page.getByRole("region", { name: "Проверка объекта устройства" }) + await object.getByLabel("Сервер проверяемого объекта").selectOption("1") + await object.getByLabel("Тип проверяемого объекта").selectOption("ethernet") + await object.getByLabel("Стабильный ID объекта").selectOption("*A") + await object.getByLabel("Операция над объектом").selectOption("ros.ethernet.update") + await object.getByRole("button", { name: "Проверить выбранный объект" }).click() + await expect(object.getByRole("status")).toContainText("Запрещено") + await expect(object.getByRole("status")).toContainText("Изменение интерфейса управления запрещено защитой") + await expect(object.getByRole("status")).toContainText("PROTECTED_OBJECT") + await expect(object.getByRole("status")).toContainText("60 вызовов/мин") + await expect(object.locator("pre")).toHaveCount(0) + expect(previews).toHaveLength(1) + expect(previews[0]).toEqual({ connectionId: id, operationId: "ros.ethernet.update", objects: [{ kind: "ethernet", id: "*A", serverId: 1 }], objectCount: 1, target: { scope: "servers", serverIds: [1] } }) + await object.getByLabel("Защита выбранного объекта").selectOption("deny") + await object.getByLabel("Причина защиты объекта").fill("Management") + await object.getByRole("button", { name: "Защитить выбранный объект" }).click() + await expect(object.getByRole("status")).toContainText("Защита сохранена") + expect(writes).toEqual([{ object: { kind: "ethernet", id: "*A", serverId: 1, protection: "deny" }, reason: "Management" }]) + await object.getByLabel("Сервер проверяемого объекта").selectOption("2") + await expect(object.getByRole("status")).toHaveCount(0) + await expect(object.getByRole("button", { name: "Защитить выбранный объект" })).toBeDisabled() + await expect(object.getByRole("button", { name: "Проверить выбранный объект" })).toBeDisabled() + await object.getByLabel("Стабильный ID объекта").selectOption("*A") + await object.getByRole("button", { name: "Защитить выбранный объект" }).click() + await expect.poll(() => writes.length).toBe(2) + expect(writes[1].object).toMatchObject({ serverId: 2, kind: "ethernet", id: "*A" }) + expect(duplicateKeys).toEqual([]) +})