From cffcfada88fc553cb1460ad99ce75dc96b86e7e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 13 Nov 2026 13:37:55 +0700 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=D1=80=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B5=D0=B9,=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BE=D0=BC=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=81=D0=BB=D1=83=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B1=D0=B0=D0=B7=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/users/page.tsx | 6 +- backend/package.json | 9 +- .../scripts/build-database-operator.test.mjs | 10 +- backend/scripts/database-operator-sources.mjs | 2 +- .../database-writer-profile-operator.mts | 67 +- ...database-writer-profile-preflight.test.mts | 160 +++- .../scripts/mcp-external-process/client.mjs | 156 +++- .../mcp-external-process/run-owned.mjs | 4 +- .../mcp-external-process/server-fixture.ts | 173 +++- .../test-database-writer-retained-tls.mjs | 105 +++ backend/scripts/test-mcp-postgres.mjs | 8 +- .../test-routing-controller-baseline.mjs | 8 +- backend/scripts/writer-profile/catalogue.mts | 4 +- .../writer-profile/operator-contract.mjs | 24 +- backend/scripts/writer-profile/publisher.mjs | 56 +- .../writer-profile/source-activation.mts | 150 ++++ .../writer-profile/source-activation.test.mts | 134 +++ .../db/migration-schema-inventory.sql.test.ts | 2 +- backend/src/modules/mcp/action-types.ts | 4 +- backend/src/modules/mcp/actions.ts | 53 +- .../src/modules/mcp/database-restore.test.ts | 61 +- .../src/modules/mcp/ecmp-preflight.test.ts | 46 +- backend/src/modules/mcp/ecmp-preflight.ts | 67 +- backend/src/modules/mcp/integration.test.ts | 55 +- ...logs-follow-sdk-cancellation-acceptance.ts | 112 ++- .../mcp/native-conservation-acceptance.ts | 283 +++++- .../modules/mcp/operation-revisions.test.ts | 8 +- ...rdinary-operation-retirement.owned.test.ts | 92 +- .../mcp/ordinary-operation-retirement.ts | 11 +- backend/src/modules/mcp/recovery-actions.ts | 29 +- backend/src/modules/mcp/resources.ts | 37 +- backend/src/modules/mcp/result-budget.test.ts | 10 + backend/src/modules/mcp/result-budget.ts | 7 + backend/src/modules/mcp/ros-prefix.test.ts | 5 +- .../modules/mcp/ros-step-verification.test.ts | 56 +- .../src/modules/mcp/ros-step-verification.ts | 66 +- backend/src/modules/mcp/routing-actions.ts | 73 +- .../routing-community-context-acceptance.ts | 40 + .../modules/mcp/routing-community-context.ts | 18 +- .../modules/mcp/routing-community-public.ts | 10 +- .../routing-community-scoped-acceptance.ts | 156 +++- .../routing-community-scoped-model.test.ts | 23 +- .../mcp/routing-community-scoped-model.ts | 8 +- .../mcp/routing-community-scoped-recovery.ts | 11 +- .../mcp/routing-community-scoped-service.ts | 23 +- .../routing-controller-active-writer.test.ts | 33 +- .../mcp/routing-controller-active-writer.ts | 10 +- .../routing-controller-baseline-acceptance.ts | 21 +- .../mcp/routing-controller-baseline.test.ts | 142 +++ .../mcp/routing-controller-baseline.ts | 15 +- backend/src/modules/mcp/routing-controls.ts | 142 ++- .../routing-forward-transport-charge.test.cjs | 2 +- .../src/modules/mcp/routing-health-checks.ts | 60 ++ .../routing-manual-inverse-history.test.ts | 13 +- .../mcp/routing-manual-inverse-store.ts | 8 +- ...uting-network-configuration-bridge.test.ts | 26 +- .../routing-network-configuration-bridge.ts | 5 +- .../mcp/routing-network-native-session.ts | 4 +- .../mcp/routing-network-native.sql.test.ts | 2 +- .../routing-network-scoped-context.test.ts | 4 +- .../modules/mcp/routing-observations-model.ts | 3 + .../src/modules/mcp/routing-observations.ts | 59 +- .../routing-original-effects-caller.test.mjs | 91 +- ...uting-original-effects-controller.test.mjs | 27 +- backend/src/modules/mcp/routing-profiles.ts | 55 +- backend/src/modules/mcp/runtime.ts | 97 ++- backend/src/modules/mcp/security.ts | 3 +- backend/src/modules/mcp/store.ts | 5 +- backend/src/modules/mcp/workers.ts | 163 ++-- .../servers/service/servers-service.ts | 9 +- .../modules/users/service/users-service.ts | 37 +- backend/src/routes/mcp-transport.ts | 4 + backend/src/routes/mcp.ts | 3 +- backend/src/routes/users.ts | 9 +- backend/src/scripts/mcp-stdio.ts | 20 +- .../fixtures/vrf-bridge-delete-ll-v33.json | 223 +++++ .../fixtures/vrf-gre-inverse-cycle-v32.json | 349 ++++++++ .../services/fixtures/vrf-projection-v2.json | 811 ++++++++++++++++++ backend/src/services/ipsec-live.ts | 4 +- .../src/services/ospf-cost-optimization.ts | 28 +- .../services/partition-write-transaction.ts | 2 +- backend/src/services/routing-filter-body.ts | 6 + .../server-partition-maintenance-lease.ts | 56 +- .../server-poll-archive-accounting.test.ts | 34 +- .../server-poll-archive-accounting.ts | 16 +- .../server-poll-durable-session.sql.test.ts | 77 +- .../services/server-poll-durable-session.ts | 13 +- ...er-poll-operation-retirement.owned.test.ts | 133 ++- .../server-poll-operation-retirement.ts | 19 +- .../src/services/server-poll-production.ts | 8 +- .../server-poll-retired-archive.test.ts | 36 +- .../services/server-poll-retired-archive.ts | 16 +- .../services/server-poll-storage-journal.ts | 7 +- .../server-poll-storage-retention.test.ts | 18 +- .../services/server-poll-storage-retention.ts | 8 +- backend/src/services/server-poll-storage.ts | 48 +- ...server-poll-terminal-archive.owned.test.ts | 2 +- .../server-poll-terminal-compaction.test.ts | 38 +- .../server-poll-terminal-compaction.ts | 65 +- .../server-probe-journal-maintenance.ts | 23 +- ...rver-probe-legacy-inspection.sql-helper.ts | 20 +- .../server-probe-legacy-inspection.ts | 16 +- ...r-probe-operation-retirement.owned.test.ts | 128 ++- .../server-probe-operation-retirement.ts | 18 +- .../services/server-probe-retention.test.ts | 20 +- ...rver-probe-storage-journal-adapter.test.ts | 10 +- .../server-probe-storage-journal-adapter.ts | 17 +- .../server-probe-storage-journal.sql.test.ts | 28 +- .../vrf-production-bridge-delete.test.ts | 79 ++ .../vrf-production-gre-cycle.test.mjs | 64 ++ .../vrf-production-peer-telemetry.test.mjs | 123 +++ .../vrf-production-transition.test.ts | 39 +- .../src/services/vrf-production-transition.ts | 23 +- .../vrf-production-wireguard-alias.test.ts | 119 +++ .../services/vrf-transition-refusal.test.mjs | 77 ++ backend/src/services/wireguard-live.ts | 4 +- backend/src/test/mcp-routing-sources.ts | 28 + backend/src/test/mcp-users-bindings.ts | 67 +- ...ing-controller-baseline-acceptance-main.ts | 10 +- backend/src/test/users-profile-permissions.ts | 49 ++ components/mcp/mcp-workspace.tsx | 85 +- components/users/user-sheet.tsx | 28 +- lib/user-save.ts | 5 +- shared/api/users.ts | 3 +- tsconfig.json | 9 +- 125 files changed, 6021 insertions(+), 602 deletions(-) create mode 100644 backend/scripts/test-database-writer-retained-tls.mjs create mode 100644 backend/scripts/writer-profile/source-activation.mts create mode 100644 backend/scripts/writer-profile/source-activation.test.mts create mode 100644 backend/src/modules/mcp/routing-controller-baseline.test.ts create mode 100644 backend/src/modules/mcp/routing-health-checks.ts create mode 100644 backend/src/services/fixtures/vrf-bridge-delete-ll-v33.json create mode 100644 backend/src/services/fixtures/vrf-gre-inverse-cycle-v32.json create mode 100644 backend/src/services/fixtures/vrf-projection-v2.json create mode 100644 backend/src/services/vrf-production-bridge-delete.test.ts create mode 100644 backend/src/services/vrf-production-gre-cycle.test.mjs create mode 100644 backend/src/services/vrf-production-peer-telemetry.test.mjs create mode 100644 backend/src/services/vrf-production-wireguard-alias.test.ts create mode 100644 backend/src/services/vrf-transition-refusal.test.mjs create mode 100644 backend/src/test/users-profile-permissions.ts diff --git a/app/(main)/users/page.tsx b/app/(main)/users/page.tsx index 8e561cde..55186232 100644 --- a/app/(main)/users/page.tsx +++ b/app/(main)/users/page.tsx @@ -74,6 +74,7 @@ function UsersContent() { const isLive = mode === "live" const canRead = can("mm:users:read") const canWrite = can("mm:users:write") + const canManagePermissions = !isLive || can("mm:settings:admin") const canReadServers = can("mm:servers:read") const [users, setUsers] = useState(() => isLive ? [] : INIT_USERS) @@ -184,8 +185,8 @@ function UsersContent() { setSaving(true); setSaveError(null); setOperationNotice(null) try { const saved = await saveUserSteps(form, checkpoint, { - create: f => createAppUser(backendUrl, userProfileRequest(f), init()), - update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()), + create: f => createAppUser(backendUrl, userProfileRequest(f, canManagePermissions), init()), + update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f, canManagePermissions), init()), read: id => getAppUser(backendUrl, id, init()), removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()), addBinding: (userId, b) => createUserBinding(backendUrl, userId, { @@ -328,6 +329,7 @@ function UsersContent() { users={users} servers={serverOptions} isLive={isLive} + canManagePermissions={canManagePermissions} backendUrl={backendUrl} saving={saving} saveError={saveError} diff --git a/backend/package.json b/backend/package.json index 12d8442b..dc63f533 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,10 @@ "test:mcp:module-completion": "tsx src/services/ipsec-server-init-model.test.ts && tsx src/modules/mcp/ipsec-server-init-actions.test.ts && tsx src/services/wireguard-revision-restore.test.ts && tsx src/modules/mcp/vrf-ticket-runtime.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-pool-dependencies.test.ts && tsx src/services/vrf-production-transition.test.ts && tsx src/services/vrf-production-action-binding.test.ts && tsx src/services/ipsec-revision-restore-model.test.ts && tsx src/modules/mcp/ipsec-revision-actions.test.ts && tsx src/modules/mcp/service-native-progress.test.ts && tsx src/services/ipsec-revision-restore.test.ts && tsx src/services/ipsec-revision-secret.test.ts", "test:mcp:module-completion:pg": "node scripts/test-mcp-postgres.mjs --module-completion --restore", "test:mcp:pg": "node scripts/test-mcp-postgres.mjs", + "test:mcp:users-bindings:pg": "node scripts/test-mcp-postgres.mjs --users-bindings", + "test:mcp:bridge-vlan:pg": "node scripts/test-mcp-postgres.mjs --bridge-vlan-configuration", "test:mcp:external-process:pg": "node scripts/mcp-external-process/run-owned.mjs", + "test:mcp:bridge-vlan:sdk": "node scripts/mcp-external-process/run-owned.mjs .. bridge-vlan", "test:traffic-flow:ownership": "tsx src/services/traffic-flow-batch-proof.test.ts && tsx src/services/traffic-flow-owned-batch.test.ts && tsx src/services/traffic-flow-owned-writer.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-writer-exclusion.test.ts && tsx src/services/traffic-flow-drain-protocol.test.ts && tsx src/services/traffic-flow-facts-rebuild-execution.test.ts && tsx src/services/traffic-flow-facts-rebuild-source.test.ts && tsx src/services/traffic-flow-facts-rebuild-classifier.test.ts && tsx src/services/traffic-flow-facts-rebuild-service.test.ts && tsx src/services/traffic-flow-ripe-prune-owned.test.ts && tsx src/services/traffic-flow-engine-owned.test.ts && tsx src/routes/traffic-flow-rebuild.test.ts && tsx src/services/traffic-flow-settings-execution.test.ts", "test:traffic-flow:rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild", "test:traffic-flow:pg": "node scripts/test-mcp-postgres.mjs --flow-writes", @@ -34,11 +37,11 @@ "test:probes": "npm run test:speed-workflow && tsx src/services/manual-data-collection-execution.test.ts && tsx src/routes/manual-data-collection.test.ts && tsx src/services/network-host-resolution.test.ts && tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts && tsx src/services/ping-measurement.test.ts && tsx src/services/uptime-probe-observations.test.ts && tsx src/services/ping-measurement-sql.test.ts", "test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/services/terminal-diagnostics.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts", "test:auth": "tsx --test src/modules/mcp/routing-manual-inverse-history.test.ts && tsx src/db/application-writer-profile-registry-structure.test.ts && tsx src/db/application-writer-profile-registry-read.test.ts && tsx src/db/application-writer-profile-registry-read-parity.test.ts && tsx src/db/application-writer-profile-format.test.ts && tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth-provenance.test.ts && tsx src/services/bandwidth-rest-speed-original.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts && npm run test:mcp", - "test:mcp": "tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline", + "test:mcp": "node scripts/vrf-production-profile.test.mjs && node src/services/vrf-production-peer-telemetry.test.mjs && node src/services/vrf-execution-diagnostic.test.mjs && node src/services/vrf-transition-refusal.test.mjs && node src/services/vrf-production-gre-cycle.test.mjs && tsx src/services/vrf-production-gre-link-local.test.ts && tsx src/services/vrf-production-bridge-delete.test.ts && tsx src/services/vrf-production-transition.test.ts && tsx --test src/services/routeros-snapshot-read.test.ts src/services/bridge-vlan-configuration.test.ts src/modules/mcp/bridge-vlan-projection.test.ts src/modules/mcp/vrf-preparation-diagnostic.test.ts && tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx --test src/services/vrf-production-wireguard-alias.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline", "test:wireguard": "tsx src/services/mikrotik-response-bounds.test.ts && tsx --test src/services/wireguard-dependency-read.test.ts && tsx src/services/wireguard-interface-update.test.ts && tsx src/services/wireguard-peer-update.test.ts && npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts && tsx src/services/wireguard-interface-create.test.ts && tsx src/services/wireguard-peer-create.test.ts && tsx src/services/wireguard-import-plan.test.ts && tsx src/services/wireguard-import-execution.test.ts && tsx src/modules/mcp/wireguard-import-actions.test.ts && tsx --test src/services/wireguard-export.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts", "test:traffic-flow": "tsx --test src/services/traffic-flow-heartbeat.test.ts src/services/traffic-flow-heartbeat-wiring.test.ts src/services/traffic-flow-heartbeat-receiver.test.ts src/services/traffic-flow-ingest-quality.test.ts src/services/traffic-flow-quality-contract.test.ts && tsx src/services/traffic-flow-catalog-read.test.ts && tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-fact-accumulator.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts && tsx src/services/geoip-manual-update.test.ts && tsx src/services/traffic-flow-overlay-model.test.ts && npm run test:traffic-flow:ownership && tsx src/services/traffic-flow-worker-platform.test.ts && tsx src/services/traffic-flow-template-cache.test.ts && tsx --test src/services/traffic-flow-template-lifetime.test.ts && tsx src/services/traffic-flow-protocol-scope.test.ts && npm run test:traffic-flow:read-budget && npm run test:traffic-flow:resource-profile", - "test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", + "test:users": "tsx ../packages/contracts/src/users.test.ts && tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/binding-target.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", "test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts", "test:recursive-routes": "tsx src/services/recursive-route-identity.test.ts && tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts", "test:config-sync": "tsx src/services/owned-routing-policy.test.ts && tsx src/services/routing-filter-body.test.ts && tsx src/services/bgp-community-policy-model.test.ts && tsx src/services/bgp-connection-read.test.ts && tsx src/services/bgp-filter-binding-model.test.ts && tsx src/services/filter-rules-write-model.test.ts && tsx src/services/filter-rules-mutation.test.ts && tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts", @@ -168,7 +171,7 @@ "test:traffic-flow:read-budget": "tsx --test src/services/traffic-flow-read-budget.test.ts src/modules/mcp/flow-read-quality.test.ts && node scripts/test-flow-read-adapters.mjs", "test:traffic-flow:read-http:pg": "node scripts/test-flow-read-http-owned.mjs", "test:traffic-flow:read-source:pg": "tsx scripts/test-flow-read-sql-owned.mts", - "test:mcp:routing-baseline": "tsx --test src/modules/mcp/routing-controller-active-writer.test.ts src/modules/mcp/routing-controller-fence.test.ts src/modules/mcp/routing-community-public.test.ts src/modules/mcp/routing-controller-overlap.test.ts", + "test:mcp:routing-baseline": "tsx --test src/modules/mcp/routing-controller-active-writer.test.ts src/modules/mcp/routing-controller-baseline.test.ts src/modules/mcp/routing-controller-fence.test.ts src/modules/mcp/routing-community-public.test.ts src/modules/mcp/routing-controller-overlap.test.ts", "test:mcp:routing-baseline:pg": "node scripts/test-routing-controller-baseline.mjs", "test:mcp:routing-baseline-inverse:pg": "node scripts/test-routing-controller-baseline.mjs --default-scoped", "test:sqlite-import-control:pg": "node scripts/test-sqlite-import-control-postgres.mjs", diff --git a/backend/scripts/build-database-operator.test.mjs b/backend/scripts/build-database-operator.test.mjs index abd86a6c..5418e4c9 100644 --- a/backend/scripts/build-database-operator.test.mjs +++ b/backend/scripts/build-database-operator.test.mjs @@ -20,7 +20,7 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8')) await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies})) const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}) - assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,19);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12) + assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,20);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12) const copied=new Set() async function copyDist(relative){ if(copied.has(relative))return;copied.add(relative) @@ -36,10 +36,10 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort()) // Exercise the real build entry point with its default backend/output paths. const scripts=path.join(projectBackend,'scripts') - assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),21) + assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),OPERATOR_SOURCES.length+2) const buildScript=path.join(scripts,'build-database-operator.mjs') const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000}) - assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,19) + assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,20) assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first) const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8') for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){ @@ -61,9 +61,9 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r const modulesToImport=first.modules.map(item=>path.join(output,item.file)) const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`) const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000}) - assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,19);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0) + assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,20);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0) const refusals=[] - for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']]]){ + for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']],['database-writer-profile-operator.mjs','database-source-activation-refused',['source-activate']]]){ const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),...args],{cwd:temporary,env,encoding:'utf8',timeout:30000}) assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event) } diff --git a/backend/scripts/database-operator-sources.mjs b/backend/scripts/database-operator-sources.mjs index a0cbb93e..632f553a 100644 --- a/backend/scripts/database-operator-sources.mjs +++ b/backend/scripts/database-operator-sources.mjs @@ -2,7 +2,7 @@ export const OPERATOR_SOURCES=Object.freeze([ 'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts', 'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs', - 'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts', + 'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts','writer-profile/source-activation.mts', 'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts', 'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts', 'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts', diff --git a/backend/scripts/database-writer-profile-operator.mts b/backend/scripts/database-writer-profile-operator.mts index 5f83673a..d5f54b96 100644 --- a/backend/scripts/database-writer-profile-operator.mts +++ b/backend/scripts/database-writer-profile-operator.mts @@ -8,10 +8,14 @@ import {approval,reconciliationApproval,poolOptions,roleKeys,assertJsDriverEnvir import {deploymentRequest} from './database-writer-bootstrap-operator.mjs' const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v) const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b)) +const sourceCommands=['source-activate','source-revoke','source-status'] export async function main(args:string[]){ + if(sourceCommands.includes(args[0]))return (await import('./writer-profile/source-activation.mjs')).sourceActivationMain(args) if(['maintenance-inspect','maintenance-resolve'].includes(args[0]))return (await import('./writer-profile/maintenance-resolution.mjs')).maintenanceMain(args) + const activePreflight=args[0]==='source-preflight',preflight=activePreflight||args[0]==='preflight' const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'] - assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length) + if(activePreflight)flags.push('activation-directory','activation-sha256') + assert((preflight||['publish','reconcile'].includes(args[0]))&&args.length===1+2*flags.length) assertJsDriverEnvironment(process.env) const values=new Map() for(let i=1;i[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source}) + const reviewedDefinition=approvedDefinition(definition) + assert.equal(reviewedDefinition.definitionSha256,pinned.definitionSha256) + if(args[0]!=='reconcile')requireCurrentCatalogueDefinition(pinned,reviewedDefinition) + const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source,tls:Object.freeze({...request.tls})}) const sourcePin={registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256} - // Preflight is the read-only lifecycle entry for protected registry evidence. - // A refused protection check never falls back to the signature-only verifier. - const verifier=args[0]==='preflight' - ?(await import('./writer-profile/protected-source-registry.mjs')).createProtectedDatabaseSourceBindingVerifier(sourcePin) - :createDatabaseSourceBindingVerifier(sourcePin) - const publisher=createConnectedPublisher(installation,(s:Parameters[0])=>collectSource44Catalogue(s,installation),verifier,pinned) - const pool=new pg.Pool(options) - // A pool-level async error never becomes raw stdout or a retry signal. - let asyncFailure=false;pool.on('error',()=>{asyncFailure=true}) - let result,primary:unknown,failed=false - try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})} - catch(error){primary=error;failed=true} - try{await pool.end()}catch(error){primary??=error;failed=true} - if(args[0]==='preflight'&&result?.state==='matched'&&result.platformProtectionVerified===true&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} - if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter, - cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} - if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} - throw primary??Error('Publisher terminal unconfirmed') + // Use the same protected registry evidence for inspection and publication. + const verifier=createProtectedDatabaseSourceBindingVerifier(sourcePin) + async function connected(sourceVerifier:typeof verifier,deadlineAt:number){ + const publisher=createConnectedPublisher(installation,(s:Parameters[0])=>collectSource44Catalogue(s,installation),sourceVerifier,pinned,reviewedDefinition) + const pool=new pg.Pool(options) + // A pool-level async error never becomes raw stdout or a retry signal. + let asyncFailure=false;pool.on('error',()=>{asyncFailure=true}) + let result,primary:unknown,failed=false + try{result=preflight?await publisher.preflightCurrentProfile(pool,{deadlineAt}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt})} + catch(error){primary=error;failed=true} + try{await pool.end()}catch(error){primary??=error;failed=true} + if(preflight&&result?.state==='matched'&&result.platformProtectionVerified===true&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} + if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter, + cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} + if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} + throw primary??Error('Publisher terminal unconfirmed') + } + const deadlineAt=Date.now()+120000 + if(!activePreflight)return connected(verifier,deadlineAt) + const {withActiveSourcePreflight}=await import('./writer-profile/source-activation.mjs') + const publisherRole=request.roles.publisher.name + const expectedSource={...request.source,databaseOid:Number(request.source.databaseOid),sessionUser:publisherRole,currentUser:publisherRole} + const result=await withActiveSourcePreflight({directory:get('activation-directory'),expectedSha256:get('activation-sha256'),sourcePin,expectedSource,tlsSpkiSha256:request.tls.spkiSha256,deadlineAt},verifier, + guardedVerifier=>connected(guardedVerifier,deadlineAt)) + assert.equal(result.event,'application-writer-profile-preflight') + return {...result,event:'application-writer-profile-source-preflight',sourceActivationMatched:true} } if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){ try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2} - catch{console.error(JSON.stringify({event:['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1} + catch(error){ + const sourceCommand=sourceCommands.includes(process.argv[2]) + const unknown=sourceCommand&&(error as NodeJS.ErrnoException|null)?.code==='SOURCE_ACTIVATION_TERMINAL_UNKNOWN' + console.error(JSON.stringify({event:sourceCommand?(unknown?'database-source-activation-unknown':'database-source-activation-refused'): + ['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':process.argv[2]==='source-preflight'?'application-writer-profile-source-preflight-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=unknown?2:1 + } } diff --git a/backend/scripts/database-writer-profile-preflight.test.mts b/backend/scripts/database-writer-profile-preflight.test.mts index 624a1207..3a5b39d4 100644 --- a/backend/scripts/database-writer-profile-preflight.test.mts +++ b/backend/scripts/database-writer-profile-preflight.test.mts @@ -6,22 +6,38 @@ import fs from 'node:fs/promises' import {createRequire} from 'node:module' import {runInNewContext} from 'node:vm' import {EventEmitter} from 'node:events' +import {generateKeyPairSync} from 'node:crypto' +import path from 'node:path' import * as format from '../src/db/application-writer-profile-format.ts' +import {MIGRATION_MANIFEST,migrationManifestDigest} from '../src/db/migration-manifest.ts' import {readCurrentApplicationWriterProfile} from '../src/db/application-writer-profile-registry-read.ts' import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts' +import {approvedDefinition} from './writer-profile/catalogue.mts' +import {BUILD44_PROVENANCE,FIXED44_ROLE_TEMPLATE} from './writer-profile/fixed44-build-definition.ts' const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg') const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8') interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;platformProtectionVerified:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean} -interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise}} -interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean}>} +interface PublisherModule { + requireCurrentCatalogueDefinition(approval:unknown,definition:unknown):string + createConnectedPublisher(installation:unknown,collect:()=>Promise,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown,definition:unknown):{ + preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise + publishCurrentProfile(pool:unknown,options:{privateKeyFile:string;expectedHeadSha256:null;journalDirectory:string;deadlineAt:number}):Promise<{outcome:string}> + } +} +interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean;sourceActivationMatched?:boolean}>} function compile(source:string,modules:Record):T{ const exports:Record={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'}) // The isolated VM loads the exact source above; tests exercise this narrow export contract. return exports as T } -function fixture(fault=''){ - const f=syntheticFixture('1'),catalogue={catalogSha256:'f'.repeat(64),definitionSha256:f.policy.definitionSha256,leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}} +const frozenDefinition=approvedDefinition({format:'application-writer-source44-definition-approval-v1',provenance:BUILD44_PROVENANCE,template:FIXED44_ROLE_TEMPLATE}) +const frozenMigrations=MIGRATION_MANIFEST.slice(0,44),markerIds=frozenMigrations.map(pin=>pin.file.slice(0,-4)) +const markerSql='SELECT id FROM public.schema_migrations ORDER BY id COLLATE "C" LIMIT $1' +function fixture(fault='',forPublication=false){ + const f=syntheticFixture('1'),catalogue={...frozenDefinition,catalogSha256:'f'.repeat(64),leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}} + const currentBuild=fault==='current-build'||fault==='current-approved-frozen-definition' + f.policy.manifestSha256=frozenDefinition.manifestSha256;f.policy.definitionSha256=frozenDefinition.definitionSha256 f.policy.catalogSha256=format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256})) if(fault==='signed-source')f.policy.source.databaseOid='999' if(fault==='signed-role')f.policy.roles.operator='99' @@ -34,30 +50,49 @@ function fixture(fault=''){ f.state.current[0].head_sha256=top.head_sha256;f.state.policy=[{policy_sha256:hash,policy_bytes:raw}] if(fault==='tamper')f.state.policy[0].policy_bytes=Buffer.from(raw.toString().replace('synthetic','tampered')) if(fault==='empty'){f.state.current=[];f.state.latest=[];f.state.policy=[]} - const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[];let clocks=0 + const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[],markerLimits:unknown[][]=[] + let clocks=0,checkouts=0 const client=Object.assign(new EventEmitter(),{query:async(sql:string,values?:unknown[])=>{ queries.push(sql) if(sql.includes('clock_timestamp()')&&++clocks===2&&fault==='late-clock')return {rows:[{now:'1'}]} + if(sql===markerSql){ + markerLimits.push(values??[]) + const ids=fault==='marker-missing'?markerIds.slice(0,-1):fault==='marker-extra'||fault==='marker-drift'&&markerLimits.length===2?[...markerIds,'9999_foreign']:markerIds + return {rows:ids.map(id=>({id}))} + } if(sql.startsWith('WITH')||sql.includes('clock_timestamp()'))return model.client.query(sql,values) if(sql.startsWith('SELECT pg_catalog.pg_advisory_unlock'))return {rows:[{released:true}]} if(sql==='ROLLBACK')return {rows:[],command:fault==='rollback-ack'?'':'ROLLBACK'} + if(sql.startsWith('INSERT INTO mmapp_control.current'))return {rows:[{id:1}]} return {rows:[],command:sql.split(' ')[0]} },release:(bad:boolean)=>{events.push('release:'+bad)}}) - const pool=new pg.Pool();pool.connect=async()=>client + const pool=new pg.Pool();pool.connect=async()=>{checkouts++;return client} const source={systemIdentifier:'123',databaseOid:'456',databaseName:'synthetic',serverVersionNum:180004,inRecovery:false} let contexts=0,verifications=0 - const installation={...f.subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}} - const approval={expiresAt:Date.now()+60000,manifestSha256:'b'.repeat(64),definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))} + const fixtureKey=forPublication?generateKeyPairSync('ed25519'):null + const subject=fixtureKey?{...f.subject,publicKeyPem:fixtureKey.publicKey.export({format:'pem',type:'spki'}).toString(),publicKeySpkiSha256:format.sha(fixtureKey.publicKey.export({format:'der',type:'spki'}))}:f.subject + const installation={...subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}} + const approval={expiresAt:Date.now()+60000,manifestSha256:['approval-manifest','current-approved-frozen-definition'].includes(fault)?migrationManifestDigest():frozenDefinition.manifestSha256,definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))} + const definition=fault==='static-definition'?{...frozenDefinition,definitionSha256:'0'.repeat(64)}:frozenDefinition + const privateKeyFile=path.resolve('modeled-fixture-key.pem'),journalDirectory=path.resolve('modeled-journal'),files=new Map() + if(fixtureKey)files.set(privateKeyFile,Buffer.from(fixtureKey.privateKey.export({format:'pem',type:'pkcs8'}))) + function file(filename:string){const value=files.get(filename);if(!value)throw Object.assign(Error('Fixture file absent'),{code:'ENOENT'});return value} + function stat(filename:string){return {size:file(filename).length,dev:1,ino:1,isFile:()=>true,isSymbolicLink:()=>false}} const modules={ - pg,'node:crypto':require('node:crypto'),'node:fs/promises':{},'node:path':require('node:path'), - './operator-files.mjs':{withJournalLease:()=>{throw Error('Preflight must not use publication journal')},writeFile:()=>{throw Error('No publication writes')}}, + pg,'node:crypto':require('node:crypto'),'node:fs/promises':{realpath:async(file:string)=>file,lstat:async(filename:string)=>stat(filename),unlink:async(filename:string)=>files.delete(filename),open:async(filename:string)=>({stat:async()=>stat(filename),readFile:async()=>Buffer.from(file(filename)),close:async()=>{}})},'node:path':path, + './operator-contract.mjs':{retainPublisherTlsPeer:(actual:unknown)=>{ + assert.equal(actual,client) + if(fault==='initial-tls')throw Error('TLS peer refused') + return ()=>{if(fault==='lost-tls'&&verifications===2)throw Error('Retained TLS lost')} + }}, + './operator-files.mjs':{withJournalLease:async(_directory:string,work:()=>Promise)=>{assert(forPublication);events.push('journal-lease');return work()},writeFile:async(filename:string,value:Buffer)=>{assert(forPublication);files.set(filename,value)}}, '../../src/db/database-source-observation.js':{captureDatabaseSourceObservation:async()=>{events.push('capture-source');return {}},assertDatabaseSourceObservationCurrent:async()=>{events.push('fresh-source');if(fault==='late-source')throw Error('Source changed')}}, - '../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:[],migrationManifestDigest:()=> 'b'.repeat(64)}, + '../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:currentBuild?MIGRATION_MANIFEST:frozenMigrations,migrationManifestDigest:()=>currentBuild?migrationManifestDigest():frozenDefinition.manifestSha256}, '../../src/db/application-writer-profile-format.js':format, - './context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'}, + './context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:forPublication?async()=>null:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'}, } - const api=compile(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval) - return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f} + const api=compile(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return fault==='catalogue-manifest'?{...catalogue,manifestSha256:migrationManifestDigest()}:fault==='catalogue-definition'?{...catalogue,definitionSha256:'0'.repeat(64)}:catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval,definition) + return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),publish:()=>api.publishCurrentProfile(pool,{privateKeyFile,journalDirectory,expectedHeadSha256:null,deadlineAt:Date.now()+30000}),queries,events,markerLimits,files,get checkouts(){return checkouts},f} } test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{ const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256) @@ -65,9 +100,17 @@ test('current44 preflight authenticates signed current and actual component inpu assert.equal(result.platformProtectionVerified,true) assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false) assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1) + assert.equal(format.canonical(f.markerLimits),'[[45]]') assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false') assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source']) }) +test('publisher refuses before SQL on a wrong peer and stops SQL when the retained TLS socket is lost',async()=>{ + const initial=fixture('initial-tls') + await assert.rejects(initial.run());assert.deepEqual(initial.queries,[]);assert.deepEqual(initial.events,['release:true']) + const lost=fixture('lost-tls') + await assert.rejects(lost.run());assert.equal(lost.events.at(-1),'release:true') + assert(!lost.queries.some(q=>q==='ROLLBACK'||q.includes('pg_advisory_unlock'))) +}) for(const fault of ['initial-unprotected','terminal-unprotected'])test('preflight cannot promote '+fault+' into protected evidence',async()=>{ const f=fixture(fault),result=await f.run();assert.equal(result.platformProtectionVerified,false) assert.equal(result.writerAuthorized,false);assert.equal(result.readyAuthorized,false) @@ -76,23 +119,64 @@ for(const fault of ['initial-unprotected','terminal-unprotected'])test('prefligh for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{ const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1) }) -test('existing operator main dispatches preflight only after protected input validation, without private key or publication',async()=>{ +for(const fault of ['current-build','current-approved-frozen-definition','approval-manifest','static-definition'])test('incompatible static manifest/definition refuses '+fault+' before either pool checkout or publication lease',async()=>{ + const f=fixture(fault,true) + await assert.rejects(f.run(),/Approved catalogue definition is not bound/) + await assert.rejects(f.publish(),/Approved catalogue definition is not bound/) + assert.equal(f.checkouts,0);assert.deepEqual(f.events,[]);assert.deepEqual(f.queries,[]) +}) +for(const fault of ['catalogue-manifest','catalogue-definition','marker-missing','marker-extra'])test('retained preflight refuses '+fault+' and rolls back',async()=>{ + const f=fixture(fault) + await assert.rejects(f.run(),error=>error instanceof Error&&/Catalogue evidence differs|Exact current migration marker/.test((error.cause as Error).message)) + assert.equal(f.checkouts,1);assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1) + assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT)\b/.test(q))) + if(fault.startsWith('marker-'))assert.equal(format.canonical(f.markerLimits),'[[45]]') + else assert.deepEqual(f.markerLimits,[]) +}) +test('publication checks exact marker set on both retained transactions and refuses late marker drift before registry writes',async()=>{ + const positive=fixture('',true) + assert.equal((await positive.publish()).outcome,'committed') + assert.equal(positive.checkouts,1);assert.equal(format.canonical(positive.markerLimits),'[[45],[45]]') + assert.equal(positive.queries.filter(q=>q==='COMMIT').length,2) + const drift=fixture('marker-drift',true) + await assert.rejects(drift.publish(),error=>error instanceof Error&&/Exact current migration marker/.test((error.cause as Error).message)) + assert.equal(drift.checkouts,1);assert.equal(format.canonical(drift.markerLimits),'[[45],[45]]') + assert.equal(drift.queries.filter(q=>q==='COMMIT').length,1);assert.equal(drift.queries.filter(q=>q==='ROLLBACK').length,1) + assert(!drift.queries.some(q=>/^(?:INSERT|UPDATE|DELETE)\b/.test(q))) + assert([...drift.files.keys()].some(filename=>filename.endsWith('pending-publication.json'))) +}) +test('operator requires the protected source verifier for preflight, publication and reconciliation',async()=>{ const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[] - const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}} - const files:Record={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'} - let failEnd=false,asyncError=false,protectedRefused=false + const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64),spkiSha256:'d'.repeat(64)}} + const files:Record={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture','signing-private.pem':'fixture'} + let failEnd=false,asyncError=false,protectedRefused=false,activationRefused=false,incompatibleDefinition=false const retainedPools:Pool[]=[] class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}} const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000,platformProtectionVerified:true} const protectedVerifier={verify:async()=>({platformProtectionVerified:true})} + const guardedVerifier={...protectedVerifier} const modules={ 'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'), './writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha}, - './writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}}, - './database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>{throw Error('Preflight must never fall back to unprotected source verification')}}, + './writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:(value:unknown)=>{events.push('reconciliation-approval');return value},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}}, + './database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value}, './writer-profile/protected-source-registry.mjs':{createProtectedDatabaseSourceBindingVerifier:(pin:{registryDirectory:string;currentHeadSha256:string})=>{events.push('protected-source');assert.equal(pin.registryDirectory,'source-registry');assert.equal(pin.currentHeadSha256,request.sourceHeadSha256);if(protectedRefused)throw Error('Protected source refused');return protectedVerifier}}, - pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{assert.equal(verifier,protectedVerifier);return {preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}}}}, - './writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)}, + './writer-profile/source-activation.mjs':{withActiveSourcePreflight:async(input:{directory:string;expectedSha256:string;sourcePin:{currentHeadSha256:string};expectedSource:unknown;tlsSpkiSha256:string;deadlineAt:number},verifier:unknown,work:(verifier:unknown)=>Promise)=>{ + assert.equal(verifier,protectedVerifier);assert.equal(input.directory,'active-registry');assert.equal(input.expectedSha256,'e'.repeat(64)) + assert.equal(input.sourcePin.currentHeadSha256,request.sourceHeadSha256);assert.equal(input.tlsSpkiSha256,request.tls.spkiSha256);assert(input.deadlineAt>Date.now()) + assert.equal(format.canonical(input.expectedSource),format.canonical({...request.source,databaseOid:Number(request.source.databaseOid),sessionUser:'publisher',currentUser:'publisher'})) + events.push('activation-admission');if(activationRefused)throw Error('Activation refused') + const value=await work(guardedVerifier);events.push('activation-terminal');return value + }}, + pg:{Pool},'./writer-profile/publisher.mjs':{requireCurrentCatalogueDefinition:()=>{if(incompatibleDefinition)throw Error('Build manifest refused')},createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{ + assert.equal(verifier,events.includes('activation-admission')?guardedVerifier:protectedVerifier) + return { + preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result}, + publishCurrentProfile:async()=>{events.push('publish');return {...result,outcome:'committed'}}, + reconcilePendingPublication:async()=>{events.push('reconcile');return {state:'confirmed',headSha256:result.headSha256}}, + } + }}, + './writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=>({definitionSha256:'b'.repeat(64),manifestSha256:frozenDefinition.manifestSha256})}, } const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])] const output=await compile(cli,modules).main(args) @@ -103,5 +187,37 @@ test('existing operator main dispatches preflight only after protected input val asyncError=false;result.platformProtectionVerified=false;await assert.rejects(compile(cli,modules).main(args),/Publisher terminal unconfirmed/) result.platformProtectionVerified=true;protectedRefused=true;const before=retainedPools.length await assert.rejects(compile(cli,modules).main(args),/Protected source refused/);assert.equal(retainedPools.length,before,'Protection failure must not open a Pool or fall back') + for(const [command,approvalEvent,outcome] of [['publish','approval','committed'],['reconcile','reconciliation-approval','confirmed']]){ + const commandArgs=[command,...args.slice(1)] + const poolCount=retainedPools.length + await assert.rejects(compile(cli,modules).main(commandArgs),/Protected source refused/) + assert.equal(retainedPools.length,poolCount) + protectedRefused=false + events.length=0;reads.length=0 + const terminal=await compile(cli,modules).main(commandArgs) + assert.equal(terminal.outcome,outcome);assert.equal(terminal.readyAuthorized,false) + assert.deepEqual(events,[approvalEvent,'transport','protected-source',command,'pool-end']) + assert.equal(reads.includes('signing-private.pem'),command==='publish') + protectedRefused=true + } + const activeArgs=['source-preflight',...args.slice(1),'--activation-directory','active-registry','--activation-sha256','e'.repeat(64)] + protectedRefused=false;activationRefused=true;events.length=0;reads.length=0 + const beforeActivation=retainedPools.length + await assert.rejects(compile(cli,modules).main(activeArgs),/Activation refused/) + assert.equal(retainedPools.length,beforeActivation) + assert.deepEqual(events,['approval','transport','protected-source','activation-admission']) + activationRefused=false;events.length=0;reads.length=0 + const active=await compile(cli,modules).main(activeArgs) + assert.equal(active.event,'application-writer-profile-source-preflight');assert.equal(active.sourceActivationMatched,true) + assert.equal(active.readyAuthorized,false);assert.equal(active.writerAuthorized,false) + assert.deepEqual(events,['approval','transport','protected-source','activation-admission','preflight','pool-end','activation-terminal']) + assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca']) + incompatibleDefinition=true;events.length=0;reads.length=0 + const beforeBuildRefusal=retainedPools.length + for(const incompatibleArgs of [args,activeArgs,['publish',...args.slice(1)]])await assert.rejects(compile(cli,modules).main(incompatibleArgs),/Build manifest refused/) + assert.equal(retainedPools.length,beforeBuildRefusal) + assert(!events.some(event=>event==='protected-source'||event==='activation-admission')) + const historical=await compile(cli,modules).main(['reconcile',...args.slice(1)]) + assert.equal(historical.outcome,'confirmed');assert.equal(historical.readyAuthorized,false) }) diff --git a/backend/scripts/mcp-external-process/client.mjs b/backend/scripts/mcp-external-process/client.mjs index 461409c7..ff9c7c1e 100644 --- a/backend/scripts/mcp-external-process/client.mjs +++ b/backend/scripts/mcp-external-process/client.mjs @@ -6,6 +6,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' const endpoint = new URL(process.env.OWN_MCP_ENDPOINT), origin = endpoint.origin assert.equal(endpoint.hostname, '127.0.0.1'); assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true') let phase = 'start', next = 0, maxResponseBytes = 0, stderrBytes = 0, bridgePid = null +const responseLimitBytes = 1024 * 1024 const pending = new Map(), cases = [] const activeClients = new Set() let closing = false @@ -22,7 +23,7 @@ async function boundedFetch(input, init) { if (response.body) { const reader = response.body.getReader() try { - for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= 1048576) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) } + for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= responseLimitBytes) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) } } finally { reader.releaseLock() } } const bytes = Buffer.concat(chunks); maxResponseBytes = Math.max(maxResponseBytes, bytes.length) @@ -36,14 +37,14 @@ function content(result) { return parsed } async function denied(work) { try { const r = await work(); assert.equal(r?.isError, true) } catch (e) { if (e?.code === 'ERR_ASSERTION') throw e; assert(!/timeout|timed out/i.test(String(e?.message)), 'A timeout is not authorization denial') } } -async function opened(mode, token) { +async function opened(mode, token, maxBufferSize = 131072) { const client = new Client({ name: 'actual-external-process-' + mode, version: '1.0.0' }) activeClients.add(client) let transport if (mode === 'http') transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { authorization: 'Bearer ' + token } }, fetch: boundedFetch }) else { transport = new StdioClientTransport({ command: process.execPath, args: ['--import', import.meta.resolve('tsx'), process.env.OWN_BRIDGE_FILE], - cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize: 131072 }) + cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize }) transport.stderr?.on('data', chunk => { stderrBytes += chunk.length; const text = String(chunk); assert(!text.includes(token)); assert(stderrBytes < 1048576) }) const start = transport.start.bind(transport) transport.start = async () => { await start(); bridgePid = transport.pid; assert(bridgePid && bridgePid !== process.pid && bridgePid !== Number(process.env.OWN_SERVER_PID)); process.send({ kind: 'bridge-pid', pid: bridgePid }) } @@ -68,6 +69,36 @@ try { phase = mode + ':initialize'; let client = await opened(mode, token) try { await scoped(client); cases.push(mode + ': separate SDK process initializes, filters tool/host scope, bounds page/result and redacts credentials') + phase = mode + ':policy-edit' + const editScope = async operationIds => { + const response = await boundedFetch(origin + '/api/mcp/connections/' + initial.connectionId, { + method: 'PUT', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Owned external SDK process', mode: 'observer', expiresAt: new Date(Date.now() + 600000).toISOString(), + permissions: ['mm:servers:read'], scope: { serverIds: [Number(process.env.OWN_ALLOWED_SERVER)], operationIds, allowGlobal: false }, limits: { callsPerMinute: 200 } }), + }) + assert.equal(response.status, 200) + } + await editScope(['logs.query']) + assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools.map(tool => tool.name), ['mm_logs_query']) + const allowedServer = Number(process.env.OWN_ALLOWED_SERVER), forbiddenServer = Number(process.env.OWN_FORBIDDEN_SERVER) + const queryLogs = arguments_ => client.callTool({ name: 'mm_logs_query', arguments: arguments_ }, undefined, { timeout: 5000 }) + const filters = { q: 'external-sdk-log-', limit: 1 } + const first = content(await queryLogs(filters)) + assert.ok(first.nextCursor) + const second = content(await queryLogs({ ...filters, cursor: first.nextCursor })) + assert.equal(second.nextCursor, null) + assert.deepEqual([first.entries, second.entries].map(entries => entries.map(({ serverId, message }) => ({ serverId, message }))), [ + [{ serverId: allowedServer, message: 'external-sdk-log-1' }], + [{ serverId: allowedServer, message: 'external-sdk-log-2' }], + ]) + await denied(() => queryLogs({ ...filters, serverId: forbiddenServer })) + cases.push(mode + ': populated log search pages return only allowed server records and reject the forbidden server') + await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, { timeout: 5000 })) + await editScope([]) + assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools, []) + await editScope(['servers.list']) + await scoped(client) + cases.push(mode + ': established client observes removed and restored tool scope through the web policy API') if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'failure') throw Error('Owned deliberate external client failure with bridge active') if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'timeout') { process.send({ kind: 'arm-cleanup-timeout' }); await new Promise(() => {}) } phase = mode + ':result-bound'; await control('large-result') @@ -86,7 +117,7 @@ try { } finally { await client.close().catch(() => {}) } } phase = 'http:body-bound'; const bound = await control('new-connection') - const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream' }, + const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream', connection: 'close' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'mm_servers_list', arguments: { padding: 'x'.repeat(1048576) } } }), signal: AbortSignal.timeout(5000) }) assert.equal(oversized.status, 413); assert((await oversized.text()).length < 65536) cases.push('actual HTTP request envelope over1MiB refused before tool execution; successful client results remain bounded') @@ -126,8 +157,123 @@ try { await denied(() => oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 })) cases.push('actual OAuth family revocation refuses established and fresh external transport; no implicit new consent granted') } finally { await oauthClient.close().catch(() => {}) } + phase = 'discovery:issue' + const catalogue = await control('discovery-connection') + const expectedNames = catalogue.names.toSorted() + assert(expectedNames.includes('mm_logs_follow')) + const discoveryByTransport = {} + for (const mode of ['http', 'stdio']) { + phase = `discovery:${mode}` + const client = await opened(mode, catalogue.token, responseLimitBytes) + try { + const names = [], cursors = new Set() + let cursor, largestPageBytes = 0, pages = 0 + do { + const page = await client.listTools(cursor ? { cursor } : {}, { timeout: 10000 }) + assert(page.tools.length > 0) + for (const tool of page.tools) { + assert.equal(tool.inputSchema.type, 'object') + names.push(tool.name) + } + const encoded = JSON.stringify(page) + assert(!encoded.includes(catalogue.token) && !encoded.includes(process.env.OWN_SERVER_SECRET)) + largestPageBytes = Math.max(largestPageBytes, Buffer.byteLength(encoded)) + assert(largestPageBytes < responseLimitBytes) + pages++ + cursor = page.nextCursor + if (cursor) { assert(!cursors.has(cursor)); cursors.add(cursor) } + assert(names.length <= expectedNames.length) + } while (cursor) + assert.deepEqual(names.toSorted(), expectedNames) + discoveryByTransport[mode] = { tools: names.length, pages, largestPageBytes } + cases.push(mode + ': external SDK discovers every delegated tool exactly once with an object input schema') + } finally { await client.close().catch(() => {}) } + } + phase = 'discovery:revoke' + await control('revoke') + for (const [mode, arm] of [['http', 'arm-log-revocation'], ['stdio', 'arm-log-revocation-after-commit'], ['http', 'arm-log-follow-revocation']]) { + phase = mode + ':' + arm + const { token } = await control(arm) + const logClient = await opened(mode, token) + try { + const follow = arm === 'arm-log-follow-revocation' + const tool = follow ? 'mm_logs_follow' : 'mm_logs_query' + const filters = { q: follow ? 'external-sdk-live-event' : 'external-sdk-log-1', limit: 1 } + if (follow) { + Object.assign(filters, { serverId: Number(process.env.OWN_ALLOWED_SERVER), waitSeconds: 0 }) + const start = content(await logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 5000 })) + assert.deepEqual(start.entries, []) + assert.equal(typeof start.cursor, 'string') + filters.cursor = start.cursor + filters.waitSeconds = 10 + } + const query = logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 10000 }) + const checked = query.then(result => { + assert.equal(result.isError, true) + const wire = JSON.stringify(result) + assert.match(wire, /OWNER_CLAIMS_REVOKED|UNAUTHORIZED/) + assert.doesNotMatch(wire, /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/) + }) + const deliver = async () => { + if (follow) await control('publish-follow-fixture-row') + await control('deliver-log-revocation') + } + await Promise.all([checked, deliver()]) + await assert.rejects(opened(mode, token)) + cases.push(mode + ': signed portal revocation ' + (mode === 'stdio' ? 'after' : 'before') + ' audit commit denies ' + tool + ' result and reconnect') + } finally { await logClient.close().catch(() => {}) } + } + phase = 'portal:revoke-export-download' + const exportAccess = await control('new-artifact-connection') + const exportClient = await opened('http', exportAccess.token) + try { + const exported = content(await exportClient.callTool({ name: 'mm_logs_export', arguments: { + serverId: Number(process.env.OWN_ALLOWED_SERVER), q: 'external-sdk-log-1', format: 'json', + } }, undefined, { timeout: 5000 })) + assert.equal(exported.rows, 1) + assert.equal(exported.downloadPath, '/api/mcp/artifacts/' + exported.artifactId) + assert.doesNotMatch(JSON.stringify(exported), /external-sdk-log-1/) + const download = jwt => boundedFetch(origin + exported.downloadPath, { headers: { authorization: 'Bearer ' + jwt } }) + const before = await download(exportAccess.ownerJwt) + assert.equal(before.status, 200) + assert.equal(before.headers.get('cache-control'), 'private, no-store') + const bytes = Buffer.from(await before.arrayBuffer()) + assert.equal(createHash('sha256').update(bytes).digest('hex'), exported.sha256) + assert.match(bytes.toString('utf8'), /external-sdk-log-1/) + const fresh = await control('revoke-artifact-owner') + for (const jwt of [exportAccess.ownerJwt, fresh.ownerJwt]) { + const refused = await download(jwt) + assert.ok([401, 403].includes(refused.status)) + assert.equal(refused.headers.has('content-disposition'), false) + assert.doesNotMatch(await refused.text(), /external-sdk-log-1/) + } + const management = await boundedFetch(origin + '/api/mcp/connections', { headers: { authorization: 'Bearer ' + fresh.ownerJwt } }) + assert.equal(management.status, 200) + cases.push('portal owner revocation blocks an existing unexpired log artifact even with refreshed owner login') + } finally { await exportClient.close().catch(() => {}) } + phase = 'portal:revoke-approved-plan' + const planAccess = await control('new-revocable-plan-connection') + const planClient = await opened('http', planAccess.token) + try { + const plan = content(await planClient.callTool({ name: 'mm_logs_settings_update_plan', arguments: { + settings: { enabled: false }, + } }, undefined, { timeout: 5000 })) + assert.equal(plan.state, 'awaiting-approval') + const approved = await boundedFetch(origin + '/api/mcp/plans/' + plan.planId + '/decision', { + method: 'POST', headers: { authorization: 'Bearer ' + planAccess.ownerJwt, 'content-type': 'application/json' }, + body: JSON.stringify({ approve: true }), + }) + assert.equal(approved.status, 200) + assert.deepEqual(await approved.json(), { ok: true }) + await control('revoke-approved-plan-owner') + await denied(() => planClient.callTool({ name: 'mm_change_apply', arguments: { + planId: plan.planId, idempotencyKey: randomUUID(), + } }, undefined, { timeout: 5000 })) + cases.push('signed portal revocation invalidates the exact approved log settings plan before apply') + } finally { await planClient.close().catch(() => {}) } process.send({ kind: 'result', result: { cases: cases.length, names: cases, externalClientPid: process.pid, serverPid: Number(process.env.OWN_SERVER_PID), bridgePid, - sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: 1048576, stdioSdkBufferLimitBytes: 131072, + sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: responseLimitBytes, stdioSdkBufferLimitBytes: 131072, + discoveryByTransport, discoveryStdioSdkBufferLimitBytes: responseLimitBytes, actualPublicStringLimitObserved: 16000, wholeGenericResultEnvelopeLimitAccepted: false, syntheticPortalJwtAndConsent: true, liveAuthPortal: false, livePortalSender: false, endUserUi: false, wholeExternalDirectionComplete: false } }) } catch (error) { process.send?.({ kind: 'failure', phase, errorType: error?.name ?? 'Error', completedCases: cases }); process.exitCode = 1 } diff --git a/backend/scripts/mcp-external-process/run-owned.mjs b/backend/scripts/mcp-external-process/run-owned.mjs index 75c122a7..65cbee18 100644 --- a/backend/scripts/mcp-external-process/run-owned.mjs +++ b/backend/scripts/mcp-external-process/run-owned.mjs @@ -12,7 +12,9 @@ const output = resolve(sourceRoot, scenario === 'bridge-vlan' ? 'tmp/mcp-bridge- 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', +if (scenario === 'acceptance') paths.push('backend/scripts/mcp-external-process/client.mjs', 'backend/scripts/mcp-external-process/server-fixture.ts', + 'backend/src/routes/mcp-portal.ts', 'backend/src/modules/mcp/portal-revocation-event.ts', 'backend/src/services/router-logs-query.ts') +else 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) diff --git a/backend/scripts/mcp-external-process/server-fixture.ts b/backend/scripts/mcp-external-process/server-fixture.ts index 51a5773e..331ef2ea 100644 --- a/backend/scripts/mcp-external-process/server-fixture.ts +++ b/backend/scripts/mcp-external-process/server-fixture.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { createHash } from "node:crypto" +import { createHash, createHmac, randomUUID } from "node:crypto" import { spawn } from "node:child_process" import { createServer } from "node:net" import { fileURLToPath, pathToFileURL } from "node:url" @@ -20,6 +20,7 @@ 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, rotateConnectionToken, setConnectionStatus, updateMcpSettings } = await import(source("modules/mcp/store.js")) +const { listActions } = await import(source("modules/mcp/actions.js")) as typeof import("../../src/modules/mcp/actions.js") const { env } = await import(source("config.js")) const { buildApp } = await import(source("index.js")) const actor = { id: "owned-external-process-owner", email: "external@example.invalid", name: "Owned external SDK fixture", apps: ["mm"], @@ -28,7 +29,27 @@ let currentId: string | undefined, app: Awaited> | u const credentials: string[] = [] const bridgePids = new Set() let childExit: Promise | undefined +let releaseLogRead: (() => void) | undefined +let logReadCompleted: Promise | undefined +let revocationOwnerId: string | undefined +const revokedLogRequests: { connectionId: string; tool: string }[] = [] +const originalConnect = pool.connect const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +async function deliverPortalRevocation(ownerId: string) { + const secret = randomUUID() + randomUUID() + process.env.MCP_PORTAL_WEBHOOK_SECRET = secret + const occurredAt = Date.now(), timestamp = String(Math.floor(occurredAt / 1000)) + const eventId = randomUUID(), body = JSON.stringify({ version: 2, eventId, userId: ownerId, type: "permissions.changed", eventOccurredAt: occurredAt }) + const signature = "sha256=" + createHmac("sha256", secret).update(timestamp + "." + body).digest("hex") + const response = await fetch(origin + "/_internal/mcp/portal/revocations", { + method: "POST", headers: { "content-type": "application/json", "x-mm-timestamp": timestamp, "x-mm-signature": signature }, + body, signal: AbortSignal.timeout(5000), + }) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { ok: true, duplicate: false }) + assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_portal_events WHERE id=$1", [eventId])).rows[0].count, 1) + return occurredAt +} function alive(pid: number) { try { process.kill(pid, 0); return true } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error } } async function drainChildAndBridges() { if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await Promise.race([childExit, delay(5000).then(() => { throw Error("Owned external child failed to exit") })]) } @@ -47,9 +68,18 @@ try { const secret = "OWNED_DEVICE_CREDENTIAL_MUST_NOT_ESCAPE" const allowed = (await pool.query("INSERT INTO servers(name,host,enabled,password) VALUES('owned allowed','192.0.2.1',false,$1) RETURNING id", [secret])).rows[0].id const forbidden = (await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned forbidden','192.0.2.2',false) RETURNING id")).rows[0].id + 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 id,name,host,'fixture','00:00:01',now(),now()-n*interval '1 second',ARRAY['system'],'info','external-sdk-log-'||n,0 + FROM servers CROSS JOIN generate_series(1,2) AS n WHERE id=ANY($1::int[])`, [[allowed, forbidden]]) 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 artifactOwner = { ...actor, id: actor.id + "-artifact" } + const signArtifactOwner = () => new SignJWT({ sub: artifactOwner.id, apps: artifactOwner.apps, permissions: artifactOwner.permissions }) + .setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) + let artifactConnectionId: string | undefined + const planOwner = { ...actor, id: actor.id + "-approved-plan", permissions: [...actor.permissions, "mm:mcp:approve"] } + let planConnectionId: string | undefined 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" @@ -123,10 +153,131 @@ try { if (message.kind !== "control" || !Number.isSafeInteger(message.id)) return try { let value: unknown - if (message.command === "new-connection") { + if (message.command === "new-revocable-plan-connection") { + assert(!planConnectionId) + const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ + name: "Owned approved plan revocation", mode: "approval", permissions: ["mm:settings:admin"], + expiresAt: new Date(Date.now() + 600000).toISOString(), + scope: { serverIds: [Number(allowed), Number(forbidden)], operationIds: ["logs.settings.update", "changes.apply"], allowGlobal: true }, + }), planOwner) + currentId = planConnectionId = issued.connection.id + const ownerJwt = await new SignJWT({ sub: planOwner.id, apps: planOwner.apps, permissions: planOwner.permissions }) + .setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) + credentials.push(issued.token, ownerJwt) + value = { token: issued.token, ownerJwt } + } else if (message.command === "revoke-approved-plan-owner") { + assert(planConnectionId && currentId === planConnectionId) + const plans = (await pool.query("SELECT id,state,approved_by FROM mcp_plans WHERE connection_id=$1", [planConnectionId])).rows + assert.equal(plans.length, 1) + assert.equal(plans[0].state, "approved") + assert.equal(plans[0].approved_by, planOwner.id) + await deliverPortalRevocation(planOwner.id) + value = { ok: true } + } else if (message.command === "new-artifact-connection") { + assert(!artifactConnectionId) + const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ + name: "Owned export revocation", mode: "observer", permissions: ["mm:servers:read"], + expiresAt: new Date(Date.now() + 600000).toISOString(), + scope: { serverIds: [Number(allowed)], operationIds: ["logs.export"], allowGlobal: false }, + }), artifactOwner) + currentId = artifactConnectionId = issued.connection.id + const ownerJwt = await signArtifactOwner() + credentials.push(issued.token, ownerJwt) + value = { token: issued.token, ownerJwt } + } else if (message.command === "revoke-artifact-owner") { + assert(artifactConnectionId && currentId === artifactConnectionId) + const occurredAt = await deliverPortalRevocation(artifactOwner.id) + while (Math.floor(Date.now() / 1000) <= Math.floor(occurredAt / 1000)) await delay(25) + const ownerJwt = await signArtifactOwner() + credentials.push(ownerJwt) + value = { ownerJwt } + } else if (["arm-log-revocation", "arm-log-revocation-after-commit", "arm-log-follow-revocation"].includes(message.command ?? "")) { + assert(!releaseLogRead) + const afterCommit = message.command === "arm-log-revocation-after-commit" + const follow = message.command === "arm-log-follow-revocation" + const owner = { ...actor, id: `${actor.id}-${follow ? "follow" : afterCommit ? "postcommit" : "precommit"}` } + revocationOwnerId = owner.id + if (follow) { + await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [allowed]) + await pool.query("UPDATE router_log_settings SET enabled=true WHERE id=1") + } + const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ + name: "Owned portal revocation during log read", mode: "observer", permissions: ["mm:servers:read"], + expiresAt: new Date(Date.now() + 600000).toISOString(), + scope: { serverIds: [Number(allowed)], operationIds: [follow ? "logs.follow" : "logs.query"], allowGlobal: false }, + }), owner) + currentId = issued.connection.id + revokedLogRequests.push({ connectionId: currentId, tool: follow ? "mm_logs_follow" : "mm_logs_query" }) + credentials.push(issued.token) + let completed!: () => void + logReadCompleted = new Promise(resolve => { completed = resolve }) + const hold = new Promise(resolve => { releaseLogRead = resolve }) + pool.connect = ((callback?: unknown) => { + if (typeof callback === "function") return Reflect.apply(originalConnect, pool, [callback]) + return (async () => { + const client = await originalConnect.call(pool) + const query = client.query, release = client.release + let completingLog = false + client.query = (async (...args: unknown[]) => { + const params = args[1] + if (typeof args[0] === "string" && args[0].startsWith("UPDATE mcp_requests SET status=$2,result=") + && Array.isArray(params) && params[1] === "completed" && String(params[2]).includes(follow ? "external-sdk-live-event" : "external-sdk-log-1")) { + completingLog = true + if (!afterCommit) { completed(); await hold } + } + const result = await Reflect.apply(query, client, args) + if (afterCommit && completingLog && args[0] === "COMMIT") { completed(); await hold } + return result + }) as typeof query + client.release = error => { + client.query = query + client.release = release + release(error) + } + return client + })() + }) as typeof originalConnect + value = { token: issued.token } + } else if (message.command === "publish-follow-fixture-row") { + assert(releaseLogRead && currentId && revokedLogRequests.at(-1)?.tool === "mm_logs_follow") + const deadline = Date.now() + 3000 + for (;;) { + const live = await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [currentId]) + if (live.rows[0].count === 1) break + assert(Date.now() < deadline, "Owned follow did not acquire a live read lease") + await delay(25) + } + 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 id,name,host,'fixture','00:00:01',now(),now(),ARRAY['system'],'info','external-sdk-live-event',0 + FROM servers WHERE id=$1`, [allowed]) + value = { ok: true } + } else if (message.command === "deliver-log-revocation") { + assert(logReadCompleted && releaseLogRead && currentId && revocationOwnerId) + try { + await Promise.race([logReadCompleted, delay(5000).then(() => { throw Error("Owned log read did not reach the barrier") })]) + await deliverPortalRevocation(revocationOwnerId) + value = { ok: true } + } finally { + pool.connect = originalConnect + releaseLogRead() + releaseLogRead = undefined + logReadCompleted = undefined + revocationOwnerId = undefined + } + } else if (message.command === "new-connection") { const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned external SDK process", mode: "observer", expiresAt: new Date(Date.now() + 1200000).toISOString(), permissions: ["mm:servers:read"], scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false }, limits: { callsPerMinute: 200 } }), actor) currentId = issued.connection.id; credentials.push(issued.token); value = { token: issued.token, connectionId: currentId } + } else if (message.command === "discovery-connection") { + const operations = listActions().map(action => action.operation) + const permissions = [...new Set(operations.flatMap(operation => operation.requiredPermissions))] + const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ + name: "Owned full catalogue discovery", mode: "approval", expiresAt: new Date(Date.now() + 600000).toISOString(), + permissions, scope: { serverIds: [Number(allowed)], operationIds: operations.map(operation => operation.id), allowGlobal: true }, + }), { ...actor, permissions: [...new Set([...actor.permissions, ...permissions])] }) + currentId = issued.connection.id + credentials.push(issued.token) + value = { token: issued.token, names: operations.map(operation => operation.toolName) } } else if (message.command === "large-result" || message.command === "reset-result") { await pool.query("UPDATE servers SET comment=$2 WHERE id=$1", [allowed, message.command === "large-result" ? "Z".repeat(40000) + "OWNED_TRUNCATION_TAIL" : ""]) value = { ok: true } @@ -144,6 +295,22 @@ try { const code = await childExit; clearTimeout(timer) if (code !== 0 || !result) { console.log("OWNED_EXTERNAL_PROCESS_FAILURE", JSON.stringify({ code, failure, stdout: stdout.slice(0,1000), stderr: stderr.slice(0,1000) })); throw Error("External SDK process did not complete") } const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows) + assert.equal(revokedLogRequests.length, 3) + assert.ok(planConnectionId) + const invalidated = (await pool.query("SELECT state FROM mcp_plans WHERE connection_id=$1", [planConnectionId])).rows + assert.deepEqual(invalidated, [{ state: "invalidated" }]) + assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_operations WHERE connection_id=$1", [planConnectionId])).rows[0].count, 0) + assert.equal((await pool.query("SELECT enabled FROM router_log_settings WHERE id=1")).rows[0].enabled, true) + assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_artifacts WHERE connection_id=$1 AND expires_at>now()", [artifactConnectionId])).rows[0].count, 1) + for (const { connectionId, tool } of revokedLogRequests) { + const revokedRead = (await pool.query("SELECT status,error_code,result FROM mcp_requests WHERE connection_id=$1 AND tool=$2 AND status='error'", [connectionId, tool])).rows + assert.equal(revokedRead.length, 1) + assert.equal(revokedRead[0].status, "error") + assert.deepEqual(Object.keys(revokedRead[0].result), ["message"]) + assert.doesNotMatch(JSON.stringify(revokedRead[0].result), /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/) + assert.ok(["OWNER_CLAIMS_REVOKED", "UNAUTHORIZED"].includes(revokedRead[0].error_code)) + assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [connectionId])).rows[0].count, 0) + } 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, denialWithoutConnection: true, @@ -151,5 +318,7 @@ try { await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2)) console.log("Owned external process SDK result:", JSON.stringify(final)) } finally { + pool.connect = originalConnect + releaseLogRead?.() try { await drainChildAndBridges() } finally { try { if (app) await app.close() } finally { await closePool() } } } diff --git a/backend/scripts/test-database-writer-retained-tls.mjs b/backend/scripts/test-database-writer-retained-tls.mjs new file mode 100644 index 00000000..dbfaee28 --- /dev/null +++ b/backend/scripts/test-database-writer-retained-tls.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import {execFileSync} from 'node:child_process' +import {randomBytes, X509Certificate} from 'node:crypto' +import {mkdtemp, mkdir, cp, readFile, rm, chmod, access} from 'node:fs/promises' +import {createServer} from 'node:net' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import EmbeddedPostgres from 'embedded-postgres' +import pg from 'pg' +import {poolOptions, retainPublisherTlsPeer} from './writer-profile/operator-contract.mjs' +import {sha} from './writer-profile/operator-files.mjs' + +const openssl = process.env.MMAPP_TLS_FIXTURE_OPENSSL || 'openssl' +const host = 'localhost' +const reservePort = async () => { + const listener = createServer() + await new Promise((ready, reject) => listener.once('error', reject).listen(0, '127.0.0.1', ready)) + return listener +} +const releasePort = listener => new Promise((ready, reject) => listener.close(error => error ? reject(error) : ready())) +const sourcePort = await reservePort() +const clonePort = await reservePort() +const port = sourcePort.address().port, clonePortNumber = clonePort.address().port +await Promise.all([releasePort(sourcePort), releasePort(clonePort)]) +const transport = {host, port, user: 'mcp_test', password: randomBytes(24).toString('hex'), database: 'postgres'} +const certFile = 'server.pem', keyFile = 'server-key.pem' +const root = await mkdtemp(join(tmpdir(), 'mm-writer-tls-owned-')) +const databaseDir = join(root, 'pg'), cloneDir = join(root, 'pg-clone') +const sourceTlsDir = join(root, 'source-tls'), cloneTlsDir = join(root, 'clone-tls') +const cluster = (databaseDir, port, tlsDir) => new EmbeddedPostgres({databaseDir, port, user: transport.user, password: transport.password, + persistent: true, createPostgresUser: false, postgresFlags: ['-h', '127.0.0.1', '-c', 'ssl=on', '-c', `ssl_cert_file=${join(tlsDir, certFile)}`, '-c', `ssl_key_file=${join(tlsDir, keyFile)}`], + onLog: () => {}, onError: () => {}}) +const postgres = cluster(databaseDir, port, sourceTlsDir), clone = cluster(cloneDir, clonePortNumber, cloneTlsDir) +let sourceStarted = false, cloneStarted = false +const fingerprint = ca => sha(new X509Certificate(ca).publicKey.export({format:'der', type:'spki'})) +const issue = async tlsDir => { + await mkdir(tlsDir, {mode:0o700}) + execFileSync(openssl, ['req', '-new', '-newkey', 'rsa:2048', '-x509', '-nodes', '-days', '1', '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost', '-keyout', keyFile, '-out', certFile], {cwd: tlsDir, stdio: 'ignore', windowsHide: true}) + await chmod(join(tlsDir, keyFile), 0o600) + return readFile(join(tlsDir, certFile)) +} +const withClient = async (config, work) => { + const pool = new pg.Pool({connectionTimeoutMillis:5000, ...config}) + let client + try { client = await pool.connect(); return await work(client) } + finally { client?.release(true); await pool.end() } +} +const query = "SELECT (pg_control_system()).system_identifier::text AS system, oid::text AS oid FROM pg_database WHERE datname=current_database()" +try { + await postgres.initialise() + const first = await issue(sourceTlsDir) + await postgres.start(); sourceStarted = true + const pin = {serverName:host, caSha256:sha(first), spkiSha256:fingerprint(first)} + const request = {tls:pin, roles:{publisher:{name:transport.user}}, source:{databaseName:transport.database}} + const baseline = await withClient(poolOptions(transport, request, first), async client => { + const retained = retainPublisherTlsPeer(client, pin) + retained() + const {rows:[source]} = await client.query(query) + const {rows:[status]} = await client.query('SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()') + assert.equal(status.ssl, true) + await client.query("CREATE TABLE owned_tls_fixture (note text PRIMARY KEY); INSERT INTO owned_tls_fixture VALUES ('original')") + const connection = client.connection, socket = connection.stream + connection.stream = undefined + try { assert.throws(retained, /retained TLS peer unavailable/) } finally { connection.stream = socket } + return source + }) + await withClient({...transport, ssl:false}, async client => assert.throws(() => retainPublisherTlsPeer(client,pin), /retained TLS peer unavailable/)) + await postgres.stop(); sourceStarted = false + await cp(databaseDir, cloneDir, {recursive:true, force:false, errorOnExist:true}) + await assert.rejects(access(join(cloneDir, keyFile)), {code:'ENOENT'}) + const second = await issue(cloneTlsDir) + assert.notEqual(fingerprint(second), pin.spkiSha256) + await postgres.start(); sourceStarted = true + await clone.start(); cloneStarted = true + const cloneTransport = {...transport, port:clonePortNumber} + const bothTrusted = Buffer.concat([first,second]) + await assert.rejects(withClient(poolOptions(cloneTransport, {...request,tls:{...pin,caSha256:sha(bothTrusted)}}, bothTrusted), + () => assert.fail('Wrong physical cluster must not reach application SQL')), /TLS SPKI refused/) + await withClient({...cloneTransport, ssl:{ca:bothTrusted, servername:host, rejectUnauthorized:true}}, async client => { + assert.equal(client.connection.stream.authorized, true) + const {rows:[source]} = await client.query(query) + assert.deepEqual(source, baseline) + assert.throws(() => retainPublisherTlsPeer(client,pin), /TLS SPKI refused/) + retainPublisherTlsPeer(client,{...pin,spkiSha256:fingerprint(second)})() + assert.deepEqual((await client.query('SELECT note FROM owned_tls_fixture')).rows, [{note:'original'}]) + await client.query("INSERT INTO owned_tls_fixture VALUES ('clone-only')") + }) + await withClient(poolOptions(transport, request, first), async client => { + retainPublisherTlsPeer(client,pin)() + assert.deepEqual((await client.query('SELECT note FROM owned_tls_fixture')).rows, [{note:'original'}]) + }) + console.log('Genuine PostgreSQL TLS: original accepted; independently writable physical clone with identical DB coordinates and a different CA-valid TLS key refused') +} finally { + const stops = await Promise.allSettled([ + cloneStarted ? clone.stop() : undefined, + sourceStarted ? postgres.stop() : undefined, + ]) + assert(stops.every(result => result.status === 'fulfilled'), 'Owned cluster stop unconfirmed') + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.match(root, /mm-writer-tls-owned-/) + await rm(root, {recursive:true, force:true, maxRetries:20, retryDelay:100}) + await assert.rejects(access(root), {code:'ENOENT'}) + console.log('Owned PostgreSQL and temporary TLS material removed') +} diff --git a/backend/scripts/test-mcp-postgres.mjs b/backend/scripts/test-mcp-postgres.mjs index b8f16b26..a410a0f3 100644 --- a/backend/scripts/test-mcp-postgres.mjs +++ b/backend/scripts/test-mcp-postgres.mjs @@ -165,6 +165,10 @@ const chrVpn = process.argv.includes('--chr-vpn-catalog') const chrFilters = process.argv.includes('--chr-filter-write') const chrLegacyOspf = process.argv.includes('--chr-ospf-legacy') const greRevision = process.argv.includes('--gre-revision') +const usersBindings = process.argv.includes('--users-bindings') +if (usersBindings && (process.argv.length !== 3 || process.argv[2] !== '--users-bindings')) throw new Error('User bindings acceptance requires its own disposable cluster') +const bridgeVlanConfiguration = process.argv.includes('--bridge-vlan-configuration') +if (bridgeVlanConfiguration && process.argv.slice(2).some(arg => arg !== '--bridge-vlan-configuration')) throw new Error('Bridge VLAN acceptance requires its own disposable cluster') const applicationAdapters = process.argv.includes('--application-adapters') const scopedRoutingDiagnostics = process.argv.includes('--scoped-routing-diagnostics') const interfaceEventsCompatibility = process.argv.includes('--interface-events-compatibility') @@ -246,7 +250,7 @@ try { if (regressionOnly) console.log('Selected regression only: primary integration and terminal restore are not run') if (profilesOnly) console.log('Profiles-only regression matrix: primary integration NOT RUN; terminal restore NOT RUN; full acceptance is not claimed') const result = regressionOnly || profilesOnly ? 0 : await new Promise((resolve, reject) => { - const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuild ? 'src/test/flow-facts-rebuild-sql.ts' : flowWrites ? 'src/services/traffic-flow-write-transaction.pg.test.ts' : nativeConservation ? 'src/modules/mcp/native-conservation-sql.test.ts' : nativeCreateId ? 'src/modules/mcp/native-create-id-http.test.ts' : ipsecAdapters ? "src/modules/mcp/ipsec-adapters-http.test.ts" : recoveryMaintenance ? "src/modules/mcp/recovery-maintenance-http.test.ts" : scopedCheckpointAdapters ? "src/modules/mcp/scoped-checkpoint-adapters-http.test.ts" : wireguardTransfer ? 'src/modules/mcp/wireguard-transfer-http.test.ts' : interfaceEventsCompatibility ? 'src/modules/mcp/interface-events-compatibility-http.test.ts' : scopedRoutingDiagnostics ? 'src/modules/mcp/scoped-routing-diagnostics-http.test.ts' : applicationAdapters ? 'src/modules/mcp/application-adapters-http.test.ts' : greRevision ? 'src/modules/mcp/gre-revision-http.test.ts' : chrGreRevisionRecovery ? 'src/modules/mcp/chr-gre-revision-recovery.test.ts' : chrGreRevision ? 'src/modules/mcp/chr-gre-revision.test.ts' : chrLegacyOspf ? 'src/modules/mcp/chr-ospf-legacy.test.ts' : chrFilters ? 'src/modules/mcp/chr-filter-write.test.ts' : chrGreMutation ? 'src/modules/mcp/chr-gre-mutation.test.ts' : chrFirewallRestore ? 'src/modules/mcp/chr-firewall-restore.test.ts' : chrAddress ? 'src/modules/mcp/chr-address-list.test.ts' : chrFirewallWrite ? 'src/modules/mcp/chr-firewall-write.test.ts' : chrVpn ? 'src/modules/mcp/chr-vpn-catalog.test.ts' : chrFirewallMove ? 'src/modules/mcp/chr-firewall-move.test.ts' : chrRecursive ? 'src/modules/mcp/chr-recursive-restore.test.ts' : chrIdentity ? 'src/modules/mcp/chr-ospf-identity-readonly.test.ts' : chrRead ? 'src/modules/mcp/chr-readonly-acceptance.test.ts' : chr ? 'src/modules/mcp/chr-acceptance.test.ts' : 'src/modules/mcp/integration.test.ts'], { + const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', usersBindings ? 'src/modules/mcp/users-bindings-owned.test.ts' : bridgeVlanConfiguration ? 'src/modules/mcp/bridge-vlan-configuration-http.test.ts' : principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuild ? 'src/test/flow-facts-rebuild-sql.ts' : flowWrites ? 'src/services/traffic-flow-write-transaction.pg.test.ts' : nativeConservation ? 'src/modules/mcp/native-conservation-sql.test.ts' : nativeCreateId ? 'src/modules/mcp/native-create-id-http.test.ts' : ipsecAdapters ? "src/modules/mcp/ipsec-adapters-http.test.ts" : recoveryMaintenance ? "src/modules/mcp/recovery-maintenance-http.test.ts" : scopedCheckpointAdapters ? "src/modules/mcp/scoped-checkpoint-adapters-http.test.ts" : wireguardTransfer ? 'src/modules/mcp/wireguard-transfer-http.test.ts' : interfaceEventsCompatibility ? 'src/modules/mcp/interface-events-compatibility-http.test.ts' : scopedRoutingDiagnostics ? 'src/modules/mcp/scoped-routing-diagnostics-http.test.ts' : applicationAdapters ? 'src/modules/mcp/application-adapters-http.test.ts' : greRevision ? 'src/modules/mcp/gre-revision-http.test.ts' : chrGreRevisionRecovery ? 'src/modules/mcp/chr-gre-revision-recovery.test.ts' : chrGreRevision ? 'src/modules/mcp/chr-gre-revision.test.ts' : chrLegacyOspf ? 'src/modules/mcp/chr-ospf-legacy.test.ts' : chrFilters ? 'src/modules/mcp/chr-filter-write.test.ts' : chrGreMutation ? 'src/modules/mcp/chr-gre-mutation.test.ts' : chrFirewallRestore ? 'src/modules/mcp/chr-firewall-restore.test.ts' : chrAddress ? 'src/modules/mcp/chr-address-list.test.ts' : chrFirewallWrite ? 'src/modules/mcp/chr-firewall-write.test.ts' : chrVpn ? 'src/modules/mcp/chr-vpn-catalog.test.ts' : chrFirewallMove ? 'src/modules/mcp/chr-firewall-move.test.ts' : chrRecursive ? 'src/modules/mcp/chr-recursive-restore.test.ts' : chrIdentity ? 'src/modules/mcp/chr-ospf-identity-readonly.test.ts' : chrRead ? 'src/modules/mcp/chr-readonly-acceptance.test.ts' : chr ? 'src/modules/mcp/chr-acceptance.test.ts' : 'src/modules/mcp/integration.test.ts'], { cwd: pollMemory ? directory : new URL('../', import.meta.url), stdio: 'inherit', env: { ...(pollMemory ? pollMemoryEnvironment : process.env), ...(routingOriginalEffects ? { MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS: 'true' } : {}), DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, @@ -257,7 +261,7 @@ try { child.once('error', reject); child.once('exit', code => resolve(code ?? 1)) }) exitCode = result - if (result === 0 && !regressionOnly && !profilesOnly && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) { + if (result === 0 && !regressionOnly && !profilesOnly && !usersBindings && !bridgeVlanConfiguration && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) { exitCode = await new Promise((resolve, reject) => { const child = spawn(process.execPath, ['--import', 'tsx', 'src/modules/mcp/database-restore.test.ts'], { cwd: new URL('../', import.meta.url), stdio: 'inherit', env: { ...process.env, diff --git a/backend/scripts/test-routing-controller-baseline.mjs b/backend/scripts/test-routing-controller-baseline.mjs index 61fe86b8..fc98a681 100644 --- a/backend/scripts/test-routing-controller-baseline.mjs +++ b/backend/scripts/test-routing-controller-baseline.mjs @@ -6,10 +6,12 @@ import { randomBytes, createHash } from 'node:crypto' import { createServer } from 'node:net' import { spawn } from 'node:child_process' -const allowed = new Set(['--default-scoped', '--main-only', '--check-only']) -if (process.argv.slice(2).some(arg => !allowed.has(arg)) || new Set(process.argv.slice(2)).size !== process.argv.slice(2).length || process.argv.includes('--default-scoped') && process.argv.includes('--main-only')) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only] [--check-only]') +const modes = ['--default-scoped', '--main-only', '--scoped-bare-readback'] +const allowed = new Set([...modes, '--check-only']), args = process.argv.slice(2) +const selected = modes.filter(flag => args.includes(flag)) +if (args.some(arg => !allowed.has(arg)) || new Set(args).size !== args.length || selected.length > 1) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only | --scoped-bare-readback] [--check-only]') const root = resolve(import.meta.dirname, '../..'), require = createRequire(import.meta.url), ts = require('typescript') -const mode = process.argv.includes('--default-scoped') ? 'default-scoped' : process.argv.includes('--main-only') ? 'main-only' : 'main-and-forward-rounds' +const mode = selected[0]?.slice(2) ?? 'main-and-forward-rounds' const target = join(root, 'backend/src/modules/mcp/routing-controller-active-writer.ts') await readFile(target) await readFile(join(root, 'backend/src/modules/mcp/routing-main-transition.ts')) diff --git a/backend/scripts/writer-profile/catalogue.mts b/backend/scripts/writer-profile/catalogue.mts index f532acf2..7c92aae8 100644 --- a/backend/scripts/writer-profile/catalogue.mts +++ b/backend/scripts/writer-profile/catalogue.mts @@ -7,7 +7,7 @@ export function approvedDefinition(value:unknown){ assert(value&&typeof value==='object');const v=value as Record assert.deepEqual(Object.keys(v).sort(),['format','provenance','template']);assert.equal(v.format,'application-writer-source44-definition-approval-v1') assert.equal(canonical(v.provenance),canonical(BUILD44_PROVENANCE));assert.equal(canonical(v.template),canonical(FIXED44_ROLE_TEMPLATE)) - return BUILD44_PROVENANCE.templateSha256 + return Object.freeze({definitionSha256:BUILD44_PROVENANCE.templateSha256,manifestSha256:BUILD44_PROVENANCE.manifestSha256}) } /** Real incremental DATA verifier on caller's retained transaction. No pool or attestor. */ export async function collectSource44Catalogue(s:{query:(sql:string,values?:unknown[])=>Promise<{rows:Record[]} >},installation:{roleBindings:ConstructorParameters[0]}){ @@ -21,7 +21,7 @@ export async function collectSource44Catalogue(s:{query:(sql:string,values?:unkn await s.query('DECLARE mm_publisher44 NO SCROLL CURSOR WITHOUT HOLD FOR '+FIXED44_CATALOGUE_BUILD_SQL,values);cursor=true for(;;){const page=(await s.query('FETCH FORWARD 128 FROM mm_publisher44')).rows;bridge.acceptPage(page);if(page.length===0)break} const result=bridge.finish();await s.query('CLOSE mm_publisher44');cursor=false - return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,leafCount:result.catalogue.leafRelations} + return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,manifestSha256:result.catalogue.manifestSha256,leafCount:result.catalogue.leafRelations} }catch(error){primary=error;failed=true;throw error} finally{ try{if(cursor)await s.query('CLOSE mm_publisher44');await s.query("SELECT pg_catalog.set_config('search_path',$1,true),pg_catalog.set_config('TimeZone',$2,true),pg_catalog.set_config('DateStyle',$3,true)",[settings[0].path,settings[0].zone,settings[0].dates])} diff --git a/backend/scripts/writer-profile/operator-contract.mjs b/backend/scripts/writer-profile/operator-contract.mjs index e52b4562..f4dad343 100644 --- a/backend/scripts/writer-profile/operator-contract.mjs +++ b/backend/scripts/writer-profile/operator-contract.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import {X509Certificate} from 'node:crypto' -import {checkServerIdentity} from 'node:tls' +import {checkServerIdentity,TLSSocket} from 'node:tls' import {sha} from './operator-files.mjs' export const roleKeys=Object.freeze(['runtime','operator','capture','publisher','control']) export const STARTUP_OPTIONS='-c statement_timeout=10000 -c lock_timeout=5000 -c client_encoding=UTF8' @@ -18,15 +18,29 @@ export function approval(value){ } // Pinned historical inspection data only; never publication authorization. export function reconciliationApproval(value){return parsedApproval(value)} +function peerError(pin,host,cert){ + const failed=checkServerIdentity(pin.serverName,cert);if(failed)return failed + try{if(host!==pin.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==pin.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')} +} + +// pg replaces connection.stream with the negotiated TLS socket. Hold its +// identity on the retained client; handshake callbacks are not session evidence. +export function retainPublisherTlsPeer(client,pin){ + const socket=client.connection?.stream + const assertCurrent=()=>{ + assert(socket instanceof TLSSocket&&client.connection?.stream===socket&&socket.encrypted&&socket.authorized&&!socket.destroyed&&socket.writable,'Publisher retained TLS peer unavailable') + } + assertCurrent() + const failed=peerError(pin,socket.servername,socket.getPeerCertificate());if(failed)throw failed + socket.disableRenegotiation() + return assertCurrent +} export function poolOptions(transport,request,ca){ assert(exact(transport,['host','port','user','password','database'])) assert(typeof transport.host==='string'&&transport.host===request.tls.serverName&&Number.isSafeInteger(transport.port)&&transport.port>0&&transport.port<=65535&&transport.user===request.roles.publisher.name&&typeof transport.password==='string'&&transport.password.length>0&&transport.password.length<=4096&&transport.database===request.source.databaseName) assert(sha(ca)===request.tls.caSha256);new X509Certificate(ca) return {host:transport.host,port:transport.port,user:transport.user,password:transport.password,database:transport.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-writer-publisher',client_encoding:'UTF8', - ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity(host,cert){ - const failed=checkServerIdentity(request.tls.serverName,cert);if(failed)return failed - try{if(host!==request.tls.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==request.tls.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')} - }}} + ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity:(host,cert)=>peerError(request.tls,host,cert)}} } export function assertPublicationApproval(current,catalogue,structure,pinned,manifestSha256){ assert(pinned.expiresAt>Date.now()&&pinned.manifestSha256===manifestSha256&&pinned.definitionSha256===catalogue.definitionSha256) diff --git a/backend/scripts/writer-profile/publisher.mjs b/backend/scripts/writer-profile/publisher.mjs index 62c8f35b..62dac349 100644 --- a/backend/scripts/writer-profile/publisher.mjs +++ b/backend/scripts/writer-profile/publisher.mjs @@ -3,6 +3,7 @@ import pg from 'pg' import {createPrivateKey,createPublicKey,sign} from 'node:crypto' import {open,lstat,realpath,unlink} from 'node:fs/promises' import {writeFile,withJournalLease} from './operator-files.mjs' +import {retainPublisherTlsPeer} from './operator-contract.mjs' import path from 'node:path' import {captureDatabaseSourceObservation,assertDatabaseSourceObservationCurrent} from '../../src/db/database-source-observation.js' import {MIGRATION_MANIFEST,migrationManifestDigest} from '../../src/db/migration-manifest.js' @@ -10,7 +11,13 @@ import {MIGRATION_MANIFEST,migrationManifestDigest} from '../../src/db/migration import {ownedContext,sameOwned,sourceCoordinates,protectedStructure,readCurrent,HISTORY,CURRENT} from './context.mjs' import {bytes,canonical,sha,nextEpoch,POLICY_DOMAIN,HEAD_DOMAIN,KEY,liveValidity,policySchema,headSchema,publicKey} from '../../src/db/application-writer-profile-format.js' -export function createConnectedPublisher(installation,collectFixedCatalogue,sourceVerifier,approval){ +export function requireCurrentCatalogueDefinition(approval,definition){ + const manifestSha256=migrationManifestDigest() + if(approval.manifestSha256!==manifestSha256||definition.manifestSha256!==manifestSha256||definition.definitionSha256!==approval.definitionSha256)throw Error('Approved catalogue definition is not bound to this build migration manifest') + return manifestSha256 +} + +export function createConnectedPublisher(installation,collectFixedCatalogue,sourceVerifier,approval,definition){ const PUBLISHER=installation.roleBindings.publisher.name class ProfilePublicationError extends Error{ automaticReplay=false @@ -29,13 +36,18 @@ async function privateKey(filename){ // detached application callbacks or cleanup/COMMIT races. async function session(pool,deadlineAt,work){ if(!(pool instanceof pg.Pool)||!Number.isSafeInteger(deadlineAt)||deadlineAt<=Date.now()||deadlineAt-Date.now()>120000)throw Error('Bounded genuine publisher pool/lifetime required') - let client,retired=false,released=false,locked=false,begun=false,commitSent=false,committed=false,confirmedValue,proposal,phase='checkout',stopReject,cleanupUnconfirmed=false + let client,assertTransport,retired=false,released=false,locked=false,begun=false,commitSent=false,committed=false,confirmedValue,proposal,phase='checkout',stopReject,cleanupUnconfirmed=false const stopped=new Promise((_,reject)=>{stopReject=reject});void stopped.catch(()=>{}) const release=bad=>{if(!client||released)return;released=true;client.off('error',fault);client.off('end',fault);try{client.release(bad)}catch{cleanupUnconfirmed=true}} const fault=()=>{if(retired)return;retired=true;cleanupUnconfirmed=true;release(true);stopReject(Error('Publisher connection/lifetime ended'))} const timer=setTimeout(fault,Math.max(1,deadlineAt-Date.now())),bounded=p=>Promise.race([p,stopped]) - const query=async(text,values)=>{if(retired||Date.now()>=deadlineAt)throw Error('Publisher lifetime closed');return bounded(client.query(text,values))} + const fence=()=>{ + if(retired||Date.now()>=deadlineAt)throw Error('Publisher lifetime closed') + try{assertTransport()}catch(error){fault();throw error} + } + const query=async(text,values)=>{fence();return bounded(client.query(text,values))} const state={query,phase:value=>{phase=value},proposal:value=>{proposal=Object.freeze(value)},begun:value=>{begun=value},commit:async value=>{ + fence() confirmedValue=Object.freeze({...value}) commitSent=true const response=await bounded(client.query('COMMIT').then(result=>{if(result.command==='COMMIT'){committed=true;begun=false}return result})) @@ -45,6 +57,7 @@ async function session(pool,deadlineAt,work){ try{ const pending=pool.connect().then(value=>{client=value;if(retired){release(true);return value}client.on('error',fault);client.on('end',fault);return value});void pending.catch(()=>{}) await bounded(pending);if(retired)throw Error('Late publisher checkout') + try{assertTransport=retainPublisherTlsPeer(client,installation.tls)}catch(error){retired=true;release(true);throw error} await query("SET statement_timeout='10s'");await query("SET lock_timeout='5s'") phase='exclusive-admission';await query('SELECT pg_catalog.pg_advisory_lock($1::bigint)',[KEY]);locked=true value=await work(state) @@ -62,7 +75,16 @@ async function session(pool,deadlineAt,work){ if(failed||cleanupUnconfirmed||retired)throw new ProfilePublicationError(commitSent||begun?'unknown':'not-committed',phase,proposal,cleanupUnconfirmed||retired,primary) return value } -async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,journalDirectory,validityMs=86400000,deadlineAt=Date.now()+120000}){ +async function observeCurrentCatalogue(s,manifestSha256){ + const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s) + if(catalogue.manifestSha256!==manifestSha256||catalogue.definitionSha256!==definition.definitionSha256)throw Error('Catalogue evidence differs from the approved migration manifest/definition') + // Inspect one extra row so an unexpected marker cannot hide after the expected prefix. + const expected=MIGRATION_MANIFEST.map(pin=>pin.file.slice(0,-4)) + const markers=(await s.query('SELECT id FROM public.schema_migrations ORDER BY id COLLATE "C" LIMIT $1',[expected.length+1])).rows.map(row=>row.id) + if(canonical(markers)!==canonical(expected))throw Error('Exact current migration marker metadata required') + return {security,catalogue,catalogSha256:sha(canonical({base:catalogue.catalogSha256,security:security.sha256}))} +} +async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,journalDirectory,validityMs=86400000,deadlineAt=Date.now()+120000},manifestSha256){ if(!Number.isSafeInteger(validityMs)||validityMs<1||validityMs>86400000||expectedHeadSha256!==null&&!(/^[a-f0-9]{64}$/).test(expectedHeadSha256))throw Error('Explicit previous head and bounded validity required') const key=await privateKey(privateKeyFile),directory=await realpath(journalDirectory),pendingFile=path.join(directory,'pending-publication.json') try{await lstat(pendingFile);throw Error('Pending publication must be reconciled before signing')}catch(error){if(error.code!=='ENOENT')throw error} @@ -73,18 +95,15 @@ async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,jou const observation=await captureDatabaseSourceObservation(facade,options) await sourceVerifier.verify(facade,observation,options) if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned source mismatch') - const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s) - const markers=(await s.query('SELECT id FROM public.schema_migrations ORDER BY id LIMIT 45')).rows.map(row=>row.id) - if(canonical(markers)!==canonical(MIGRATION_MANIFEST.map(pin=>pin.file.slice(0,-4))))throw Error('Exact current migration marker metadata required') + const {security,catalogue,catalogSha256}=await observeCurrentCatalogue(s,manifestSha256) const old=await readCurrent(s,installation,{allowEmpty:true,validNow:false}) if((old?.headSha256??null)!==expectedHeadSha256)throw Error('Expected protected current head mismatch') - const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})) - if(approval.expiresAt<=Date.now()||approval.manifestSha256!==migrationManifestDigest()||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independently approved policy differs from actual source44 catalogue') - if(old&&(canonical(old.policy.source)!==canonical(sourceCoordinates(first))||canonical(old.policy.roles)!==canonical(security.roles)||old.policy.manifestSha256!==migrationManifestDigest()||old.policy.definitionSha256!==catalogue.definitionSha256||old.policy.catalogSha256!==catalogSha256))throw Error('Renewal cannot adopt a changed source/subject/policy') + if(approval.expiresAt<=Date.now()||approval.catalogSha256!==catalogSha256)throw Error('Independently approved policy differs from actual catalogue') + if(old&&(canonical(old.policy.source)!==canonical(sourceCoordinates(first))||canonical(old.policy.roles)!==canonical(security.roles)||old.policy.manifestSha256!==manifestSha256||old.policy.definitionSha256!==catalogue.definitionSha256||old.policy.catalogSha256!==catalogSha256))throw Error('Renewal cannot adopt a changed source/subject/policy') await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options);sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read')) await s.query('COMMIT');s.begun(false) const number=old?nextEpoch(old.epoch):'1',now=Date.now() - const body=policySchema.parse({format:'application-writer-policy-v1',installationId:installation.installationId,epoch:number,issuedAt:now,notBefore:now,notAfter:now+validityMs,source:sourceCoordinates(first),sourceObservationSha256:sha(canonical(observation)),roles:security.roles,manifestSha256:migrationManifestDigest(),definitionSha256:catalogue.definitionSha256,catalogSha256,futureLeaves:'fixed12-native-policy-v1'}) + const body=policySchema.parse({format:'application-writer-policy-v1',installationId:installation.installationId,epoch:number,issuedAt:now,notBefore:now,notAfter:now+validityMs,source:sourceCoordinates(first),sourceObservationSha256:sha(canonical(observation)),roles:security.roles,manifestSha256,definitionSha256:catalogue.definitionSha256,catalogSha256,futureLeaves:'fixed12-native-policy-v1'}) const envelope=(domain,body)=>bytes({body,signature:sign(null,Buffer.from(domain+canonical(body)),key).toString('base64url')}) const policyBytes=envelope(POLICY_DOMAIN,body),policySha256=sha(policyBytes) const head=headSchema.parse({format:'application-writer-head-v1',installationId:installation.installationId,epoch:number,policySha256,previousHeadSha256:old?.headSha256??null}) @@ -101,8 +120,8 @@ async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,jou await s.query(`SELECT id FROM mmapp_control.${CURRENT} WHERE id=1 FOR UPDATE`) const current=await readCurrent(s,installation,{allowEmpty:true,validNow:false}) if((current?.headSha256??null)!==expectedHeadSha256)throw Error('CAS predecessor changed') - const againSecurity=await protectedStructure(s,installation),againCatalogue=await collectFixedCatalogue(s) - if(sha(canonical({base:againCatalogue.catalogSha256,security:againSecurity.sha256}))!==catalogSha256)throw Error('Policy drift during retained exclusive publication') + const again=await observeCurrentCatalogue(s,manifestSha256) + if(again.catalogSha256!==catalogSha256)throw Error('Policy drift during retained exclusive publication') if(Date.now()>=body.notAfter||Date.now()>=approval.expiresAt)throw Error('Policy or approval expired before publication dispatch') await s.query(`INSERT INTO mmapp_control.${HISTORY}(installation_id,epoch,head_sha256,previous_installation_id,previous_epoch,previous_head_sha256,policy_sha256,policy_bytes,head_bytes) VALUES($1::uuid,$2::bigint,$3,$4::uuid,$5::bigint,$6,$7,$8::bytea,$9::bytea)`,[installation.installationId,number,headSha256,old?installation.installationId:null,old?.epoch??null,old?.headSha256??null,policySha256,policyBytes,headBytes]) const changed=old?await s.query(`UPDATE mmapp_control.${CURRENT} SET epoch=$1::bigint,head_sha256=$2 WHERE id=1 AND installation_id=$3::uuid AND epoch=$4::bigint AND head_sha256=$5 RETURNING id`,[number,headSha256,installation.installationId,old.epoch,old.headSha256]):await s.query(`INSERT INTO mmapp_control.${CURRENT}(id,installation_id,epoch,head_sha256) VALUES(1,$1::uuid,$2::bigint,$3) RETURNING id`,[installation.installationId,number,headSha256]) @@ -136,17 +155,17 @@ async function inspectPublication(pool,proposal,{deadlineAt=Date.now()+120000}={ // Read-only diagnostics on the actual protected publisher session. This does // not admit application/operator writes, bootstrap, restore, or READY. async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){ + const manifestSha256=requireCurrentCatalogueDefinition(approval,definition) return session(pool,deadlineAt,async s=>{ s.phase('profile-preflight');await s.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');s.begun(true) const first=await ownedContext(s,installation,PUBLISHER,'publisher-read') if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch') const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt} const observation=await captureDatabaseSourceObservation(facade,options),initialSource=await sourceVerifier.verify(facade,observation,options) - const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s) + const {security,catalogue,catalogSha256}=await observeCurrentCatalogue(s,manifestSha256) const current=await readCurrent(s,installation) if(!current)throw Error('Current signed policy required for preflight') - const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest() - if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue') + if(approval.expiresAt<=Date.now()||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual catalogue') if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue') const terminalSource=await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options) sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read')) @@ -173,7 +192,10 @@ async function reconcilePendingPublication(pool,{journalDirectory,deadlineAt=Dat } return Object.freeze({ preflightCurrentProfile, - publishCurrentProfile:(pool,options)=>withJournalLease(options.journalDirectory,()=>publishCurrentProfile(pool,options)), + publishCurrentProfile:async(pool,options)=>{ + const manifestSha256=requireCurrentCatalogueDefinition(approval,definition) + return withJournalLease(options.journalDirectory,()=>publishCurrentProfile(pool,options,manifestSha256)) + }, inspectPublication, reconcilePendingPublication:(pool,options)=>withJournalLease(options.journalDirectory,()=>reconcilePendingPublication(pool,options)), }) diff --git a/backend/scripts/writer-profile/source-activation.mts b/backend/scripts/writer-profile/source-activation.mts new file mode 100644 index 00000000..b51a1ab4 --- /dev/null +++ b/backend/scripts/writer-profile/source-activation.mts @@ -0,0 +1,150 @@ +/** Deployment operator state. Store outside PGDATA, backups and runtime mounts. */ +import assert from 'node:assert/strict' +import {constants} from 'node:fs' +import {lstat,open,rename} from 'node:fs/promises' +import path from 'node:path' +import {z} from 'zod' +import {sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceBindingBody} from '../../src/db/database-source-binding.js' +import {protectedDirectory,readProtected,sha,withJournalLease} from './operator-files.mjs' +import {readProtectedDatabaseSourceBindingRegistry, type createProtectedDatabaseSourceBindingVerifier} from './protected-source-registry.mjs' + +const digest=z.string().regex(/^[a-f0-9]{64}$/),uuid=z.string().uuid() +const sourcePin=z.object({registryDirectory:z.string().min(1).max(4096),installationId:uuid,publicKeySpkiSha256:digest,currentHeadSha256:digest}).strict() +const directoryPath=z.string().min(1).max(4096) +const approval=z.object({format:z.literal('database-source-activation-approval-v1'),activationDirectory:directoryPath,sourcePin,tlsSpkiSha256:digest, + expectedActivationSha256:digest.nullable()}).strict() +const generation=z.object({generation:uuid,headSha256:digest,bindingSha256:digest,tlsSpkiSha256:digest}).strict() +const stateSchema=z.object({format:z.literal('database-source-activation-v1'),activationDirectory:directoryPath,installationId:uuid,sourceIdentityId:uuid, + publicKeySpkiSha256:digest,status:z.enum(['active','revoked']),generations:z.array(generation).min(1).max(256)}).strict() +const maximum=131072, filename='source-activation.json' +type State=z.infer +const parse=(bytes:Buffer):unknown=>JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(bytes)) + +async function present(file:string){ + try{await lstat(file);return true}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return false;throw error} +} + +// Caller holds the same protected lease used by all activation writers. +async function current(directory:string){ + const file=path.join(directory,filename) + assert(!await present(file+'.pending'),'Unresolved activation transition') + if(!await present(file))return undefined + const bytes=await readProtected(file,maximum),state=stateSchema.parse(parse(bytes)) + assert.equal(state.activationDirectory,directory,'Activation record belongs to another directory') + assert(sourceBindingBytes(state).equals(bytes),'Noncanonical activation record') + for(const field of ['generation','headSha256','tlsSpkiSha256'] as const){ + assert.equal(new Set(state.generations.map(item=>item[field].toLowerCase())).size,state.generations.length,'Reused activation identity') + } + return {state,activationSha256:sha(bytes)} +} + +async function persist(directory:string,state:State){ + await protectedDirectory(directory) + const bytes=sourceBindingBytes(stateSchema.parse(state)) + assert(bytes.length<=maximum) + const file=path.join(directory,filename),pending=file+'.pending' + const parent=await open(directory,constants.O_RDONLY|constants.O_DIRECTORY|constants.O_NOFOLLOW) + let confirmed=false,cleanupUnconfirmed=false + try{ + const handle=await open(pending,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600) + try{await handle.writeFile(bytes);await handle.sync()}finally{await handle.close()} + try{await rename(pending,file);await parent.sync()} + catch{throw Object.assign(Error('Activation terminal requires inspection'),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})} + confirmed=true + }finally{try{await parent.close()}catch{cleanupUnconfirmed=confirmed}} + return {activationSha256:sha(bytes),cleanupUnconfirmed} +} + +function receipt(state:State,activationSha256:string,outcome:'committed'|'observed'){ + return {event:'database-source-activation',outcome,activationSha256,status:state.status, + ordinal:state.generations.length,...state.generations.at(-1),generationAuthenticated:false,readyAuthorized:false,automaticReplay:false} +} + +type SourceVerifier=ReturnType + +/** Keep revocation and generation changes fenced throughout one read-only SQL preflight. */ +export async function withActiveSourcePreflight(input:{directory:string,expectedSha256:string,sourcePin:z.infer,expectedSource:DatabaseSourceBindingBody['source'],tlsSpkiSha256:string,deadlineAt:number}, + verifier:SourceVerifier,work:(guardedVerifier:SourceVerifier)=>Promise){ + const {directory,deadlineAt}=input + const expectedSha256=digest.parse(input.expectedSha256),tlsSpkiSha256=digest.parse(input.tlsSpkiSha256) + const pin=sourcePin.parse(input.sourcePin) + return withJournalLease(directory,async()=>{ + async function readActive(){ + assert(Date.now()){ + await readActive() + const evidence=await verifier.verify(...args) + assert(evidence.platformProtectionVerified&&evidence.operatorSignatureVerified&&evidence.currentHeadPinVerified&&evidence.genuineSourceFactsMatched,'Unprotected activation source evidence') + assertGeneration(evidence) + await readActive() + sourceChecks++ + return evidence + }}) + const result=await work(guardedVerifier) + assert.equal(sourceChecks,2,'Initial and terminal source evidence required') + await readActive() + return result + }) +} + +export async function sourceActivationMain(args:string[]){ + const command=args[0], flags=command==='source-activate'?['directory','approval','approval-sha256']: + command==='source-revoke'?['directory','expected-sha256']:['directory'] + assert(['source-activate','source-revoke','source-status'].includes(command)&&args.length===1+2*flags.length) + const values=new Map() + for(let i=1;i{ + const before=await current(directory) + if(command==='source-status'){ + assert(before,'Activation record absent') + return receipt(before.state,before.activationSha256,'observed') + } + if(command==='source-revoke'){ + const expected=digest.parse(values.get('expected-sha256')) + assert(before?.activationSha256===expected&&before.state.status==='active','Active activation digest changed') + const revoked={...before.state,status:'revoked' as const} + const saved=await persist(directory,revoked) + return {...receipt(revoked,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed} + } + const request=approval.parse(parse(await readProtected(values.get('approval')!,16384,digest.parse(values.get('approval-sha256'))))) + assert.equal(request.activationDirectory,directory,'Approval belongs to another activation directory') + assert.equal(before?.activationSha256??null,request.expectedActivationSha256,'Activation predecessor changed') + const registry=await readProtectedDatabaseSourceBindingRegistry(request.sourcePin,{deadlineAt:Date.now()+120000}) + const previous=before?.state.generations??[] + assert.equal(registry.head.ordinal,previous.length+1,'Source ordinal changed') + assert.equal(registry.head.previousHeadSha256,previous.at(-1)?.headSha256??null,'Signed predecessor differs') + if(before){ + assert.equal(registry.head.installationId,before.state.installationId) + assert.equal(registry.head.sourceIdentityId,before.state.sourceIdentityId) + assert.equal(registry.publicKeySpkiSha256,before.state.publicKeySpkiSha256) + } + assert(!previous.some(item=>item.generation.toLowerCase()===registry.head.generation.toLowerCase()||item.tlsSpkiSha256===request.tlsSpkiSha256),'Generation or TLS key was already used') + const next:State={format:'database-source-activation-v1',activationDirectory:directory,installationId:registry.head.installationId, + sourceIdentityId:registry.head.sourceIdentityId,publicKeySpkiSha256:registry.publicKeySpkiSha256,status:'active', + generations:[...previous,{generation:registry.head.generation,headSha256:registry.headSha256, + bindingSha256:registry.head.bindingSha256,tlsSpkiSha256:request.tlsSpkiSha256}]} + const saved=await persist(directory,next) + return {...receipt(next,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed} + }) +} diff --git a/backend/scripts/writer-profile/source-activation.test.mts b/backend/scripts/writer-profile/source-activation.test.mts new file mode 100644 index 00000000..6e7b051c --- /dev/null +++ b/backend/scripts/writer-profile/source-activation.test.mts @@ -0,0 +1,134 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import {createHash,generateKeyPairSync,randomUUID} from 'node:crypto' +import {createRequire} from 'node:module' +import {runInNewContext} from 'node:vm' +import {provisionDatabaseSourceBindingOffline} from '../database-source-binding-operator.ts' +import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts' +import type * as Activation from './source-activation.mts' + +const require=createRequire(import.meta.url),ts=require('typescript') +const source=await fs.readFile(new URL('./source-activation.mts',import.meta.url),'utf8') +const sha=(value:Buffer)=>createHash('sha256').update(value).digest('hex') + +test('protected activation transition model preserves revocation and refuses concurrent, stale or reused generations',async()=>{ + const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'mm-source-activation-test-'))) + try{ + // Genuine issuer signatures and filesystem files. Windows models the + // protection/lease boundary and directory fsync used on the POSIX target. + const lock=path.join(root,'test-lease'),state=path.join(root,'source-activation.json') + const files={sha,protectedDirectory:async(directory:string)=>{assert.equal(directory,root)}, + readProtected:async(filename:string,maximum:number,expected?:string)=>{ + assert.equal(path.dirname(filename),root) + const bytes=await fs.readFile(filename);assert(bytes.length<=maximum) + if(expected)assert.equal(sha(bytes),expected) + return bytes + },async withJournalLease(directory:string,work:()=>Promise){ + assert.equal(directory,root);const handle=await fs.open(lock,'wx') + try{return await work()}finally{await handle.close();await fs.unlink(lock)} + }} + let parentFailure='' + const modelFs={...fs,open:async(filename:string,flags:number,mode?:number)=>filename===root? + {sync:async()=>{if(parentFailure==='sync')throw Error('fixture directory sync failure')}, + close:async()=>{if(parentFailure==='close')throw Error('fixture directory close failure')}}:fs.open(filename,flags,mode)} + const imports:Record={'node:fs/promises':modelFs,'./operator-files.mjs':files, + './protected-source-registry.mjs':{readProtectedDatabaseSourceBindingRegistry:readDatabaseSourceBindingRegistry}, + '../../src/db/database-source-binding.js':{sourceBindingBytes,sourceBindingCanonical}} + const load=()=>{ + const exports={} + const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText + runInNewContext(compiled,{exports,require:(name:string)=>imports[name]??require(name),Buffer,TextDecoder}) + return exports as typeof Activation + } + const {withActiveSourcePreflight}=load() + let {sourceActivationMain:run}=load() + const privateKeyFile=path.join(root,'issuer.pem'),key=generateKeyPairSync('ed25519').privateKey + await fs.writeFile(privateKeyFile,key.export({format:'pem',type:'pkcs8'}),{mode:0o600}) + const installationId=randomUUID(),sourceIdentityId=randomUUID() + const issue=async(previousPin?:DatabaseSourceDeploymentPin,generation=randomUUID())=>{ + return (await provisionDatabaseSourceBindingOffline({privateKeyFile,previousPin,outputDirectory:path.join(root,randomUUID()), + request:{installationId,sourceIdentityId,generation,source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic', + sessionUser:'publisher',currentUser:'publisher',inRecovery:false,serverVersionNum:180004}}})).deploymentPin + } + const status=['source-status','--directory',root] + const revoke=(expected:string)=>['source-revoke','--directory',root,'--expected-sha256',expected] + const activate=async(pin:DatabaseSourceDeploymentPin,tlsSpkiSha256:string,expectedActivationSha256:string|null,activationDirectory=root)=>{ + const bytes=sourceBindingBytes({format:'database-source-activation-approval-v1',activationDirectory,sourcePin:pin,tlsSpkiSha256,expectedActivationSha256}) + const file=path.join(root,randomUUID()+'.json');await fs.writeFile(file,bytes,{mode:0o600,flag:'wx'}) + return ['source-activate','--directory',root,'--approval',file,'--approval-sha256',sha(bytes)] + } + const g1=await issue(undefined,'5ac5e8bd-0eb2-498b-901a-dd2010cd4b31'),g2=await issue(g1),g3=await issue(g2) + await assert.rejects(run(await activate(g1,'1'.repeat(64),null,path.join(root,'other'))),/another activation directory/) + const start=await activate(g1,'1'.repeat(64),null) + const races=await Promise.allSettled([run(start),run(start)]) + assert.deepEqual(races.map(value=>value.status).sort(),['fulfilled','rejected'],races.map(value=>value.status==='rejected'?String(value.reason):value.status).join(', ')) + const first=await run(status) + assert.equal(first.ordinal,1);assert.equal(first.headSha256,g1.currentHeadSha256) + assert.equal(first.generationAuthenticated,false) + const registry=await readDatabaseSourceBindingRegistry(g1) + const evidence={format:'database-source-binding-evidence-v1' as const, + installationId:registry.head.installationId,sourceIdentityId:registry.head.sourceIdentityId,generation:registry.head.generation, + ordinal:registry.head.ordinal,bindingSha256:registry.head.bindingSha256,headSha256:registry.headSha256, + operatorSignatureVerified:true as const,currentHeadPinVerified:true as const, + genuineSourceFactsMatched:true as const,platformProtectionVerified:true as const,generationAuthenticated:false as const, + allWriterDrainAuthenticated:false as const,readyAuthorized:false as const} + // The actual preflight exercises the checked-out PostgreSQL client. The guard fixture models its source verifier. + type Verifier=Parameters[1] + const verifierArgs=[] as unknown as Parameters + const preflight={directory:root,sourcePin:g1,expectedSource:registry.body.source,expectedSha256:first.activationSha256,tlsSpkiSha256:'1'.repeat(64),deadlineAt:Date.now()+30000} + const refuseConnect=()=>{throw Error('Reached SQL connection before activation was trusted')} + const verifier={verify:async()=>evidence} + await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:'0'.repeat(64)},verifier,refuseConnect),/activation digest changed/) + await assert.rejects(withActiveSourcePreflight({...preflight,tlsSpkiSha256:'2'.repeat(64)},verifier,refuseConnect),/Activation key differs/) + await assert.rejects(withActiveSourcePreflight({...preflight,sourcePin:g2},verifier,refuseConnect),/differs from signed source generation/) + await assert.rejects(withActiveSourcePreflight({...preflight,expectedSource:{...preflight.expectedSource,currentUser:'different'}},verifier,refuseConnect),/differs from reviewed configuration/) + let verifications=0 + const result=await withActiveSourcePreflight(preflight,{verify:async()=>{verifications++;return evidence}},async guarded=>{ + assert.equal(await guarded.verify(...verifierArgs),evidence) + await assert.rejects(run(revoke(first.activationSha256)),{code:'EEXIST'}) + assert.equal(await guarded.verify(...verifierArgs),evidence) + return 'matched' + }) + assert.equal(result,'matched');assert.equal(verifications,2) + await assert.rejects(fs.access(lock),{code:'ENOENT'}) + await assert.rejects(withActiveSourcePreflight(preflight,verifier,async guarded=>{ + await guarded.verify(...verifierArgs) + }),/Initial and terminal source evidence required/) + let reads=0 + await assert.rejects(withActiveSourcePreflight(preflight,{verify:async()=>++reads===1?evidence:{...evidence,bindingSha256:'0'.repeat(64)}},async guarded=>{ + await guarded.verify(...verifierArgs) + await guarded.verify(...verifierArgs) + }),/differs from signed source generation/) + await fs.writeFile(state+'.pending','interrupted',{flag:'wx'}) + await assert.rejects(withActiveSourcePreflight(preflight,verifier,refuseConnect),/Unresolved activation transition/) + await fs.unlink(state+'.pending') + await assert.rejects(run(start),/predecessor changed/) + const revoked=await run(revoke(first.activationSha256)) + await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:revoked.activationSha256},verifier,refuseConnect),/activation digest changed/) + run=load().sourceActivationMain + assert.equal((await run(status)).status,'revoked') + await assert.rejects(run(revoke(first.activationSha256)),/digest changed/) + await assert.rejects(run(await activate(g3,'3'.repeat(64),revoked.activationSha256)),/ordinal changed/) + const second=await run(await activate(g2,'2'.repeat(64),revoked.activationSha256)) + assert.equal(second.ordinal,2);assert.equal(second.status,'active') + await assert.rejects(run(await activate(g3,'1'.repeat(64),second.activationSha256)),/already used/) + const repeatedGeneration=await issue(g2,first.generation!.toUpperCase()) + await assert.rejects(run(await activate(repeatedGeneration,'3'.repeat(64),second.activationSha256)),/already used/) + await assert.rejects(run(await activate({...g3,currentHeadSha256:'0'.repeat(64)},'3'.repeat(64),second.activationSha256))) + assert.equal((await run(status)).activationSha256,second.activationSha256) + parentFailure='sync' + await assert.rejects(run(revoke(second.activationSha256)),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'}) + parentFailure='close' + const observed=await run(status) + assert.equal(observed.status,'revoked') + const third=await run(await activate(g3,'3'.repeat(64),observed.activationSha256)) + assert.equal(third.outcome,'committed') + assert('cleanupUnconfirmed' in third && third.cleanupUnconfirmed===true) + await fs.writeFile(state+'.pending','interrupted',{flag:'wx'}) + await assert.rejects(run(status),/Unresolved activation transition/) + await assert.rejects(run(revoke(third.activationSha256)),/Unresolved activation transition/) + }finally{await fs.rm(root,{recursive:true,force:true})} +}) diff --git a/backend/src/db/migration-schema-inventory.sql.test.ts b/backend/src/db/migration-schema-inventory.sql.test.ts index 92e8d8b0..9fb3606d 100644 --- a/backend/src/db/migration-schema-inventory.sql.test.ts +++ b/backend/src/db/migration-schema-inventory.sql.test.ts @@ -53,7 +53,7 @@ try { assert(baseline.sections.constraints.length > 100) assert(baseline.sections.functions.length > 0) assert.deepEqual(compareMigrationSchemas(baseline, await capture(reference.connection)), { equal: true, evidenceOnly: true, enrollmentAuthorized: false, sha256: baseline.sha256 }) - cases.push("all36 pinned migrations produce repeatable complete PG18 catalog evidence on an actual checked-out read-only backend") + cases.push(`all ${inventory.length} pinned migrations produce repeatable complete PG18 catalog evidence on an actual checked-out read-only backend`) } finally { await reference.connection.end() } const independent = await database("independent") try { diff --git a/backend/src/modules/mcp/action-types.ts b/backend/src/modules/mcp/action-types.ts index 9de83f70..1c7fc291 100644 --- a/backend/src/modules/mcp/action-types.ts +++ b/backend/src/modules/mcp/action-types.ts @@ -26,11 +26,11 @@ export interface ChangeEvidence { requiresHuman?: boolean; verificationScope?: "configuration" | "acknowledgement"; validUntil?: string; - observationChecks?: Array<{ id: string; srcServerId: number; dstServerId: number; srcInterface: string; family: string; table: string; fingerprint: string; validUntil: string }>; + observationChecks?: Array<{ id: string; srcServerId: number; dstServerId: number; srcInterface: string; dstInterface?: string; metric?: "pingAt" | "bandwidthAt"; family: string; table: string; fingerprint: string; validUntil: string }>; ospfOwnerGraph?: { family: "ipv4" | "ipv6"; sourceSecretProofs: Array<{ serverId: number; proof: string }>; instances: Array<{ serverId: number; fingerprint: string }> }; managementChannels?: Array<{ channelId: string; serverId: number; expiresAt: string }>; recovery?: { step: ChangeStep; expected: unknown; validUntil: string; authorityProfileId: string }; - healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record }>; + healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record; nextHopInterface?: { name: string; id: string } }>; warnings: string[]; } export interface McpAction { diff --git a/backend/src/modules/mcp/actions.ts b/backend/src/modules/mcp/actions.ts index ef7b0faf..ca463f43 100644 --- a/backend/src/modules/mcp/actions.ts +++ b/backend/src/modules/mcp/actions.ts @@ -13,13 +13,13 @@ import { setTimeout as sleep } from "node:timers/promises" import { logsFollowCancellation } from "./logs-follow-cancellation.js" import { z } from "zod" import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts" -import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users" +import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, interfaceCatalogStateSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users" import type { McpOperationDescriptor } from "@mmapp/contracts/mcp" 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, validateUserBindingCreate, UsersServiceError } from "../users/service/users-service.js" +import { listUsers, getUserById, getInterfaceCatalogState, projectLegacyInterfaceCatalog, 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" @@ -31,7 +31,7 @@ import { readLogStream } from "../../services/router-logs-query.js" import { subscribeLogDevice } from "../../services/router-logs-collector.js" import { rosResources, readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate } from "./resources.js" import { registerMcpOperation, listMcpOperations } from "./registry.js" -import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, redact } from "./security.js" +import { accessGeneration, decryptSecret, encryptSecret, fingerprint, MAX_MCP_PUBLIC_ARRAY_ITEMS, MAX_MCP_PUBLIC_STRING_LENGTH, McpError, redact } from "./security.js" import { validatePrincipal } from "./store.js" import type { McpAction, ActionContext, ChangeEvidence } from "./action-types.js" import { registerRoutingActions } from "./routing-actions.js" @@ -79,7 +79,7 @@ import { prepareGreNativeChange, registerGreNativeServices } from "./gre-native- import { registerVpnCatalogActions } from "./vpn-catalog-actions.js" import { registerEvoBgpActions } from "./evobgp-actions.js" import { registerRoutingProfileActions } from "./routing-profiles.js" -import { registerRoutingControls } from "./routing-controls.js" +import { readRpfDependencies, registerRoutingControls } from "./routing-controls.js" import { registerLifecycleActions } from "./lifecycle-actions.js" import { registerRecoveryActions } from "./recovery-actions.js" import { registerPathActions } from "./path-verification.js" @@ -156,7 +156,7 @@ for (const resource of rosResources) { ? z.object({ serverId, fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict() : z.object({ serverId, id: rowId.optional(), fields: resource.fields.default({}), ...(resource.id === "vrf" ? { productionAdmission: z.literal(true).optional() } : {}) }).strict() const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete" - const previousVersion = action === "create" ? (["ospf_template", "wireguard_peer", "wireguard"].includes(resource.id) ? 3 : 2) : resource.id === "ipsec_peer" || resource.id === "certificate" && action === "delete" ? 2 : resource.id === "wireguard" && action === "update" ? 3 : resource.id === "ospf_template" && action !== "delete" || resource.id === "wireguard_peer" || resource.id === "wireguard" ? 2 : 1 + 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" || resource.id === "ip_settings" ? 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" ? (action === "delete" ? 3 : 2) : 0) + (["bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 1 : 0), prepare: async (input, context) => { @@ -195,6 +195,8 @@ for (const resource of rosResources) { const references: ChangeEvidence["objects"] = [{ kind: resource.id, id: id ?? String(fields.name ?? "new"), serverId: sid }] if (resource.id.endsWith("settings")) references.push({ kind: resource.id, id: "singleton", serverId: sid }) const snapshots = [before] + const rpf = resource.id === "ip_settings" && fields["rp-filter"] ? await readRpfDependencies(sid, fields["rp-filter"], before.sourceProof) : undefined + if (rpf) snapshots.push(...rpf.snapshots) if (["ospf_instance", "ospf_area", "ospf_template", "bfd_configuration", "bgp_instance", "bgp_connection", "vrf"].includes(resource.id)) await addRoutingDependencies(sid, resource.id, action, current, fields, snapshots, references) if (["ipv4_route", "ipv6_route", "routing_rule", "bgp_connection"].includes(resource.id)) { const tables = await readRosSnapshot(sid, "routing_table") @@ -271,7 +273,7 @@ for (const resource of rosResources) { ...(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: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])], + warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(rpf?.warnings ?? []), ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])], } }, }) @@ -316,11 +318,33 @@ add("users.list", "mm_users_list", "Пользователи", "users", "mm:user add("users.get", "mm_users_get", "Пользователь по ID", "users", "mm:users:read", "read", "global", z.object({ id: rowId }).strict(), { read: async (input, context) => ({ user: scopedUser(await getUserById(String(input.id)), context) }), }) +const invalidInterfaceCatalog = () => new McpError("STATE_UNAVAILABLE", "Не подтверждён контекст каталога интерфейсов.", 502) +async function readMcpInterfaceCatalog(serverId: number) { + const state = await getInterfaceCatalogState(serverId).catch(error => { + if (error instanceof UsersServiceError) { + if (error.status === 404) throw new McpError("NOT_FOUND", "Сервер не найден", 404) + if (error.status === 502) throw invalidInterfaceCatalog() + } + throw error + }) + const parsed = interfaceCatalogStateSchema.safeParse(state) + if (!parsed.success || parsed.data.serverId !== serverId) throw invalidInterfaceCatalog() + return parsed.data +} +function catalogueExceedsPublicLimits(value: unknown): boolean { + if (typeof value === "string") return value.length > MAX_MCP_PUBLIC_STRING_LENGTH + if (Array.isArray(value)) return value.length > MAX_MCP_PUBLIC_ARRAY_ITEMS || value.some(catalogueExceedsPublicLimits) + return value !== null && typeof value === "object" && Object.values(value).some(catalogueExceedsPublicLimits) +} +function intactInterfaceCatalog(result: T): T { + if (catalogueExceedsPublicLimits(result)) throw new McpError("BUDGET_EXCEEDED", "Каталог интерфейсов превышает предел ответа MCP.", 413) + return result +} add("users.interfaces.read", "mm_user_interfaces_list", "Каталог интерфейсов для привязок пользователей", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), { - read: async input => ({ interfaces: await listInterfaceCatalog(Number(input.serverId)) }), + read: async input => intactInterfaceCatalog({ interfaces: projectLegacyInterfaceCatalog(await readMcpInterfaceCatalog(Number(input.serverId))) }), }) add("users.interfaces.state", "mm_user_interfaces_state_get", "Состояние каталога интерфейсов для привязок", "users", "mm:users:read", "read", "servers", z.object({ serverId }).strict(), { - read: async input => getInterfaceCatalogState(Number(input.serverId)), + read: async input => intactInterfaceCatalog(await readMcpInterfaceCatalog(Number(input.serverId))), }) for (const action of ["create", "update", "delete", "binding_add", "binding_remove"] as const) { const schema = action === "create" ? z.object({ user: appUserCreateSchema.omit({ role: true, sections: true, servers: true }).strict() }).strict() @@ -465,7 +489,18 @@ add("logs.settings.update", "mm_logs_settings_update_plan", "Изменение prepare: async (input, context) => { const ids = (await pool.query<{ id: number }>("SELECT id FROM servers")).rows.map(row => Number(row.id)) if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Глобальная настройка сборщика затрагивает скрытые серверы") - return { target: { scope: "global" }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: await getLogSettings(), steps: [{ kind: "service", service: "logs.settings.update", input }], warnings: ["Отключение сбора и уменьшение retention влияют на последующую доступность истории."] } + const before = await getLogSettings() + const settings = logSettingsPatchSchema.parse(input.settings) + const warnings: string[] = [] + if (before.enabled && settings.enabled === false) warnings.push("После применения сборщик прекратит получать новые события.") + if (settings.retentionDays !== undefined && settings.retentionDays < before.retentionDays) { + warnings.push(`Срок хранения сокращается с ${before.retentionDays} до ${settings.retentionDays} суток. При последующей очистке удаляются записи, полученные более ${settings.retentionDays} суток назад.`) + } + return { + target: { scope: "global" }, + objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), + snapshots: [], before, steps: [{ kind: "service", service: "logs.settings.update", input }], warnings, + } }, }) diff --git a/backend/src/modules/mcp/database-restore.test.ts b/backend/src/modules/mcp/database-restore.test.ts index 4b6ecd94..886aa928 100644 --- a/backend/src/modules/mcp/database-restore.test.ts +++ b/backend/src/modules/mcp/database-restore.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { randomUUID, createHmac } from "node:crypto" +import { randomUUID, createHash, createHmac } from "node:crypto" import { readFile, writeFile } from "node:fs/promises" import path from "node:path" import type { PoolClient } from "pg" @@ -8,7 +8,7 @@ import { pool, closePool } from "../../db/index.js" import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp" import { type AuthUser } from "../../lib/permissions.js" import { accessGeneration, mcpStateDirectory } from "./security.js" -import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js" +import { createMcpConnection, authenticateMcpToken, validatePrincipal, updateMcpSettings } from "./store.js" import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js" import { uploadRestoreArchive, readRestoreReceipt, executeMcpDatabaseRestore } from "./database-restore.js" import { exportSystemDatabaseBackup, restoreSystemDatabaseBackup } from "../../services/system-database-backup.js" @@ -51,25 +51,39 @@ try { await updateMcpSettings({ enabled: true }, actor) await pool.query("CREATE TABLE mcp_restore_acceptance_marker (value text NOT NULL)") await pool.query("INSERT INTO mcp_restore_acceptance_marker VALUES('before')") - const dump = await exportSystemDatabaseBackup() - assert.equal(dump.buffer.subarray(0, 5).toString(), "PGDMP") - if (verifyData) dataExpectation = await (await import("./database-restore-data-equivalence.sql-helper.js")).prepareOwnedRestoreDataExpectation(dump.buffer) - const revokedOwner = { id: "restore-revocation-after-backup", issuedAt: Date.now() - 60000 } - const portalSecret = "restore-acceptance-only-portal-secret-32", stamp = String(Math.floor(Date.now() / 1000)) - const portalEvent = Buffer.from(JSON.stringify({ eventId: randomUUID(), userId: revokedOwner.id, type: "permissions.changed" })) - await acceptPortalRevocation(portalEvent, stamp, "sha256=" + createHmac("sha256", portalSecret).update(stamp + ".").update(portalEvent).digest("hex"), portalSecret) - const source = await uploadRestoreArchive(dump.buffer, actor) - const encrypted = await readFile(path.join(mcpStateDirectory(), "restore-vault", source.archiveRef + ".archive.bin")) - assert.ok(!encrypted.equals(dump.buffer)) - assert.ok(!encrypted.subarray(0, 5).equals(Buffer.from("PGDMP"))) - await assert.rejects(uploadRestoreArchive(dump.buffer, { ...actor, permissions: ["mm:mcp:admin"] })) - await assert.rejects(uploadRestoreArchive(Buffer.from("invalid"), actor)) const serverIds = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id)) // Combined owned profiles retain their evidence/disabled synthetic servers. // The human restore fixture grants this exact catalog instead of inheriting // the ordinary five-server default; production policy guards stay unchanged. assert(serverIds.length <= 1000) const issue = (owner = actor) => createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Database restore acceptance", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:settings:admin"], scope: { allowGlobal: true, serverIds, operationIds: ["database.restore"] }, limits: { serversPerPlan: Math.max(1, serverIds.length) } }), owner) + const cutoff = Date.now() - 1000 + const revokedOwner = { ...actor, id: "restore-revocation-after-backup", issuedAt: cutoff - 1 } + const oldAccess = await issue(revokedOwner), oldPrincipal = await authenticateMcpToken(oldAccess.token) + const readHistoricalAccess = async () => (await pool.query<{ status: string; credential_current: boolean; generation: string }>( + "SELECT c.policy->>'status' AS status, k.revoked_at IS NULL AND k.expires_at>now() AS credential_current, c.generation FROM mcp_connections c JOIN mcp_credentials k ON k.connection_id=c.id WHERE c.id=$1 AND k.id=$2", + [oldPrincipal.connectionId, oldPrincipal.credentialId], + )).rows[0] + const beforeDumpAccess = await readHistoricalAccess() + assert.deepEqual(beforeDumpAccess, { status: "active", credential_current: true, generation: await accessGeneration() }) + const dump = await exportSystemDatabaseBackup() + assert.equal(dump.buffer.subarray(0, 5).toString(), "PGDMP") + if (verifyData) dataExpectation = await (await import("./database-restore-data-equivalence.sql-helper.js")).prepareOwnedRestoreDataExpectation(dump.buffer) + const portalSecret = "restore-acceptance-only-portal-secret-32", stamp = String(Math.floor(Date.now() / 1000)) + const eventId = randomUUID() + const portalEvent = Buffer.from(JSON.stringify({ version: 2, eventId, userId: revokedOwner.id, type: "permissions.changed", eventOccurredAt: cutoff })) + assert.deepEqual(await acceptPortalRevocation(portalEvent, stamp, "sha256=" + createHmac("sha256", portalSecret).update(stamp + ".").update(portalEvent).digest("hex"), portalSecret), { ok: true, duplicate: false }) + const cutoffFile = path.join(mcpStateDirectory(), "owner-revocations", createHash("sha256").update(revokedOwner.id).digest("hex") + ".json") + const cutoffBytes = await readFile(cutoffFile) + assert.deepEqual(JSON.parse(cutoffBytes.toString("utf8")), { invalidBefore: cutoff }) + assert.deepEqual(await readHistoricalAccess(), { ...beforeDumpAccess, status: "revoked", credential_current: false }) + await assert.rejects(validatePrincipal(oldPrincipal), { code: "OWNER_CLAIMS_REVOKED" }) + const source = await uploadRestoreArchive(dump.buffer, actor) + const encrypted = await readFile(path.join(mcpStateDirectory(), "restore-vault", source.archiveRef + ".archive.bin")) + assert.ok(!encrypted.equals(dump.buffer)) + assert.ok(!encrypted.subarray(0, 5).equals(Buffer.from("PGDMP"))) + await assert.rejects(uploadRestoreArchive(dump.buffer, { ...actor, permissions: ["mm:mcp:admin"] })) + await assert.rejects(uploadRestoreArchive(Buffer.from("invalid"), actor)) const issued = await issue(), principal = await authenticateMcpToken(issued.token) const other = await issue({ ...actor, id: "another-owner" }), otherPrincipal = await authenticateMcpToken(other.token) await assert.rejects(invokeMcpTool(otherPrincipal, "mm_database_restore_plan", { archiveRef: source.archiveRef, expectedSha256: source.sha256 })) @@ -144,13 +158,26 @@ try { assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE id=$1", [operation.id])).rows[0].n, 0) assert.notEqual(await accessGeneration(), generation) assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_owner_revocations WHERE owner_id=$1", [revokedOwner.id])).rows[0].n, 0) - await assert.rejects(assertPortalClaimsCurrent(revokedOwner)) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_portal_events WHERE id=$1", [eventId])).rows[0].n, 0) + assert.deepEqual(await readHistoricalAccess(), beforeDumpAccess) + assert.deepEqual(await readFile(cutoffFile), cutoffBytes) + // Both paths must report the external owner cutoff, independently of the + // blanket generation change that also invalidates older connections. + await assert.rejects(assertPortalClaimsCurrent(revokedOwner), { code: "OWNER_CLAIMS_REVOKED" }) + await assert.rejects(authenticateMcpToken(oldAccess.token), { code: "OWNER_CLAIMS_REVOKED" }) + await assert.rejects(validatePrincipal(oldPrincipal), { code: "OWNER_CLAIMS_REVOKED" }) await assert.rejects(authenticateMcpToken(issued.token)) await assert.rejects(readRestoreReceipt(operation.id, { ...actor, permissions: ["mm:mcp:read"] })) // The applied receipt is durable before the terminal callback releases its // last SQL lease/exclusive admission. Await that existing owner, rather than // racing its cleanup with the stale-callback/no-replay assertion below. await waitForDatabaseRetirement() + await assert.rejects(issue(revokedOwner), { code: "OWNER_CLAIMS_REVOKED" }) + const refreshedOwnerAccess = await issue({ ...revokedOwner, issuedAt: cutoff + 1 }) + const refreshedOwner = await validatePrincipal(await authenticateMcpToken(refreshedOwnerAccess.token)) + assert.equal(refreshedOwner.owner_id, revokedOwner.id) + assert.equal(refreshedOwner.generation, await accessGeneration()) + assert.notEqual(refreshedOwner.generation, beforeDumpAccess.generation) // A stale callback or process replay cannot invoke pg_restore a second time. await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'") await executeMcpDatabaseRestore(operation.id, principal) @@ -159,7 +186,7 @@ try { if (dataExpectation) { console.log("Owned restore data negative cases: foreign owner, wrong operation and unconfirmed receipt refused; repeat validation writes no audit; full equality observed before consumers resume") } - console.log("✓ Terminal restore: real pg_dump/pg_restore transaction, encrypted human upload, approval, global protection, request drain, external receipt, revoked tokens, preserved external owner revocation and no replay") + console.log("✓ Terminal restore: real pg_dump/pg_restore transaction, encrypted human upload, approval, global protection, request drain, external receipt, restored live credential refused by external Portal cutoff, post-cutoff claims admitted and no replay") // Archive authentication is checked before a terminal attempt can replace data. await updateMcpSettings({ enabled: true }, actor) diff --git a/backend/src/modules/mcp/ecmp-preflight.test.ts b/backend/src/modules/mcp/ecmp-preflight.test.ts index 334df0a4..0cef0604 100644 --- a/backend/src/modules/mcp/ecmp-preflight.test.ts +++ b/backend/src/modules/mcp/ecmp-preflight.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { analyzeEcmpPreflight, preflightObservation, preflightPaths, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js" +import { analyzeEcmpPreflight, duplicateEcmpAdjacency, ecmpAdjacency, preflightObservation, preflightPaths, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js" function fixture(): Record { const result = Object.fromEntries(Object.keys(preflightPaths("ipv4")).map(key => [key, { rows: [], error: null }])) as Record result.routes.rows = [1, 2].map(index => ({ ".id": `*${index}`, "dst-address": "192.0.2.0/24", "routing-table": "main", gateway: `192.0.2.${index}`, "immediate-gw": `192.0.2.${index}%gre${index}`, disabled: "false", active: index === 1 ? "true" : "false", distance: String(index * 10) })) result.settings.rows = [{ "rp-filter": "no", "ipv4-multipath-hash-policy": "l3", "multipath-hash-policy": "l4" }] + result.tables.rows = [{ ".id": "*main", name: "main", fib: "true" }] result.connectionTracking.rows = [{ enabled: "auto" }] return result } @@ -15,6 +16,23 @@ test("preflight observes unequal/inactive candidates without claiming packet/pat assert.equal(result.decision, "review-required"); assert.equal(result.independentPaths, "not-proven"); assert.equal(result.networkValidation, "not-performed") assert.equal(result.hashPolicy, "l3"); assert.deepEqual(result.findings, []) }) +test("preflight flags missing, ambiguous or inactive FIB without accepting unrelated tables", () => { + for (const tables of [ + [], + [{ ".id": "*main", name: "main" }], + [{ ".id": "*main", name: "main", fib: "" }], + [{ ".id": "*main", name: "main", fib: "false" }], + [{ ".id": "*main", name: "main", fib: "true", disabled: "true" }], + [{ ".id": "*main", name: "main", fib: "true", invalid: "true" }], + [{ ".id": "*main", name: "main", fib: "true" }, { ".id": "*alias", name: "main", fib: "true" }], + ]) { + const sources = fixture() + sources.tables.rows = [...tables, { ".id": "*other", name: "other", fib: "true" }] + const result = analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources) + assert.equal(result.configurationObserved, false) + assert.deepEqual(result.findings.find(item => item.code === "FIB_TABLE_UNCONFIRMED")?.ids, ["*1", "*2"]) + } +}) test("NAT, FastTrack, marks, notrack, strict RPF and shared egress require explicit review", () => { const sources = fixture() sources.filter.rows = [{ ".id": "*fast", action: "fasttrack-connection", disabled: "false" }, { ".id": "*disabled", action: "fasttrack-connection", disabled: "true" }] @@ -52,3 +70,29 @@ test("IPv6 uses IPv6 paths/hash policy and explicitly has no IPv4 RPF setting", assert.deepEqual(result.rpf, { supported: false, mode: null }); assert.equal(result.hashPolicy, "l4") assert.ok(!result.findings.some(item => item.code.startsWith("RPF"))) }) +test("ECMP identifies adjacency by canonical address and egress link", () => { + const duplicate = (first: string, second: string) => duplicateEcmpAdjacency([ecmpAdjacency(first), ecmpAdjacency(second)]) + assert.ok(duplicate("fe80::1%gre1@main", "fe80:0:0:0:0:0:0:1%gre1@tenant")) + assert.ok(!duplicate("fe80::1%gre1", "fe80::1%gre2")) + assert.ok(!duplicate("192.0.2.1%gre1", "192.0.2.2%gre1")) + assert.ok(duplicate("192.0.2.1", "192.0.2.1%gre1")) + assert.equal(ecmpAdjacency("fe80::1"), null) + assert.equal(ecmpAdjacency("192.0.2.1%gre1,192.0.2.2%gre2"), null) + assert.equal(ecmpAdjacency("gre1"), null) + assert.deepEqual(ecmpAdjacency("gre1", new Set(["gre1"])), { address: null, interface: "gre1" }) + const sources = fixture() + sources.routes.rows[1]["immediate-gw"] = sources.routes.rows[0]["immediate-gw"] + assert.ok(analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources).findings.some(item => item.code === "DUPLICATE_IMMEDIATE_GATEWAY")) +}) +test("interface-only ECMP next-hop requires a unique observed name", () => { + const sources = fixture() + sources.routes.rows[0]["immediate-gw"] = "gre2" + const analyze = (rows: Record[]) => analyzeEcmpPreflight("ipv4", ["*1", "*2"], sources, preflightObservation(rows)) + const known = analyze([{ ".id": "*gre", name: "gre2" }]) + assert.equal(known.configurationObserved, true) + assert.ok(known.findings.some(item => item.code === "SHARED_EGRESS_INTERFACE")) + const ambiguous = analyze([{ ".id": "*gre", name: "gre2" }, { ".id": "*other", name: "gre2" }]) + assert.equal(ambiguous.configurationObserved, false) + assert.ok(ambiguous.findings.some(item => item.code === "EGRESS_INTERFACE_UNKNOWN")) + assert.equal(analyze([{ name: "gre2" }]).sources.interfaces.observed, false) +}) diff --git a/backend/src/modules/mcp/ecmp-preflight.ts b/backend/src/modules/mcp/ecmp-preflight.ts index 929de402..300af941 100644 --- a/backend/src/modules/mcp/ecmp-preflight.ts +++ b/backend/src/modules/mcp/ecmp-preflight.ts @@ -1,4 +1,16 @@ +import { ipv6ToInt } from "../../services/traffic-flow-ip.js" +import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js" +import { routeGatewayContext } from "./routing-community-context-model.js" + type Row = Record +export function confirmedEcmpTable(rows: readonly Row[], name: string) { + const matches = rows.filter(row => row.name === name) + if (matches.length !== 1) return undefined + const table = matches[0], id = table[".id"] + // The /routing/table catalogue can return an empty FIB value for a non-FIB table. + if (typeof id !== "string" || !id.trim() || table.fib === "" || !isConfirmedRoutingFibTable(table)) return undefined + return { id } +} export type PreflightObservation = { rows: Row[]; error: string | null } export const preflightPaths = (family: "ipv4" | "ipv6") => { const base = family === "ipv4" ? "ip" : "ipv6" @@ -17,12 +29,48 @@ export function preflightObservation(raw: unknown, singleton = false): Preflight const text = (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null const flag = (value: unknown) => value === true || value === "true" || value === "yes" ? true : value === false || value === "false" || value === "no" ? false : null +export function ecmpAdjacency(value: unknown, interfaces?: ReadonlySet) { + if (typeof value !== "string" || !value || /[\r\n,]/.test(value)) return null + try { + const hop = routeGatewayContext(value) + const address = hop.family === 6 ? ipv6ToInt(hop.address) : hop.address + // fe80::/10 needs a confirmed link to identify the adjacency. + if (address === null || (typeof address === "bigint" && address >> 118n === 0x3fan && hop.interface === null)) return null + return { address, interface: hop.interface } + } catch { + return interfaces?.has(value) ? { address: null, interface: value } : null + } +} + +export function confirmedEcmpInterfaces(rows: readonly Row[]) { + const names = new Map() + const ambiguous = new Set() + for (const row of rows) { + if (typeof row.name !== "string" || !row.name.trim() || typeof row[".id"] !== "string" || !row[".id"].trim()) continue + if (names.has(row.name)) ambiguous.add(row.name) + names.set(row.name, row[".id"]) + } + for (const name of ambiguous) names.delete(name) + return names +} + +export function confirmedEcmpInterfaceNames(rows: readonly Row[]) { + return new Set(confirmedEcmpInterfaces(rows).keys()) +} + +export function duplicateEcmpAdjacency(hops: ReturnType[]) { + return hops.some((hop, index) => hop && hops.slice(0, index).some(previous => + previous && previous.address === hop.address + && (previous.interface === hop.interface || previous.interface === null || hop.interface === null))) +} + /** Configuration evidence only. Rule presence does not prove packet matching, * physical independence, NAT affinity, return path or traffic distribution. */ -export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record) { +export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record, inventory?: PreflightObservation) { const findings: { code: string; level: "review" | "unknown"; resource: string; ids: string[]; objectCount: number; idsTruncated: boolean; message: string }[] = [] const add = (code: string, level: "review" | "unknown", resource: string, ids: string[], message: string) => findings.push({ code, level, resource, ids: ids.slice(0, 100), objectCount: ids.length, idsTruncated: ids.length > 100, message }) - for (const [resource, observation] of Object.entries(observed)) if (observation.error) add("OBSERVATION_UNAVAILABLE", "unknown", resource, [], observation.error) + const sources = inventory ? { ...observed, interfaces: inventory } : observed + for (const [resource, observation] of Object.entries(sources)) if (observation.error) add("OBSERVATION_UNAVAILABLE", "unknown", resource, [], observation.error) const candidates = routeIds.map(id => observed.routes.rows.find(row => row[".id"] === id)) const selected = candidates.filter((row): row is Row => !!row) if (selected.length !== routeIds.length) add("ROUTE_NOT_OBSERVED", "unknown", "routes", routeIds.filter((_id, index) => !candidates[index]), "Не все выбранные маршруты подтверждены") @@ -30,9 +78,20 @@ export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[] if (routeSummary.some(row => !row.prefix || !row.table || !row.gateway || !row.immediateGateway || row.disabled === null)) add("ROUTE_CONTEXT_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждены prefix/table/gateway/disabled; отсутствие свойства не считается значением по умолчанию") if (routeSummary.some(row => row.disabled === true)) add("DISABLED_ROUTE", "review", "routes", routeSummary.filter(row => row.disabled === true).map(row => row.id), "В наборе есть отключённые маршруты") if (new Set(routeSummary.map(row => row.prefix).filter(Boolean)).size > 1 || new Set(routeSummary.map(row => row.table).filter(Boolean)).size > 1) add("DIFFERENT_ROUTE_CONTEXTS", "review", "routes", routeSummary.map(row => row.id), "Prefix или routing table выбранных маршрутов различаются") + if (observed.tables.error === null) { + const unconfirmed = new Set() + for (const table of new Set(routeSummary.map(row => row.table))) { + if (table !== null && !confirmedEcmpTable(observed.tables.rows, table)) unconfirmed.add(table) + } + const affected = routeSummary.filter(row => row.table !== null && unconfirmed.has(row.table)) + if (affected.length) add("FIB_TABLE_UNCONFIRMED", "unknown", "routes", affected.map(row => row.id), "Для выбранных маршрутов не подтверждена однозначная активная таблица FIB") + } const knownGateways = routeSummary.map(row => row.gateway).filter(Boolean) if (new Set(knownGateways).size !== knownGateways.length) add("DUPLICATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Next-hop выбранных маршрутов повторяется") - const interfaces = routeSummary.map(row => row.immediateGateway?.split("%")[1]?.split("@")[0] || null) + const names = inventory?.error === null ? confirmedEcmpInterfaceNames(inventory.rows) : undefined + const hops = selected.map(row => ecmpAdjacency(row["immediate-gw"], names)) + if (duplicateEcmpAdjacency(hops)) add("DUPLICATE_IMMEDIATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Выбранные маршруты разрешаются в один и тот же next-hop") + const interfaces = hops.map(hop => hop?.interface ?? null) if (interfaces.some(iface => iface === null)) add("EGRESS_INTERFACE_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждён интерфейс immediate gateway") else if (new Set(interfaces).size < interfaces.length) add("SHARED_EGRESS_INTERFACE", "review", "routes", routeSummary.map(row => row.id), "Несколько next-hop используют один интерфейс; независимость путей не подтверждена") const maybeEnabled = (resource: PreflightSource) => { @@ -59,5 +118,5 @@ export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[] if (!connectionTracking) add("CONNECTION_TRACKING_UNKNOWN", "unknown", "connectionTracking", [], "Режим connection tracking не подтверждён") else if (connectionTracking === "no" || connectionTracking === "false") add("CONNECTION_TRACKING_DISABLED", "review", "connectionTracking", [], "Connection tracking отключён; проверить зависимости NAT/stateful firewall") return { family, decision: "review-required", configurationObserved: !findings.some(item => item.level === "unknown"), networkValidation: "not-performed", independentPaths: "not-proven", routeSummary, hashPolicy, rpf: { supported: family === "ipv4", mode: rpf }, connectionTracking, - sources: Object.fromEntries(Object.entries(observed).map(([key, value]) => [key, { observed: value.error === null, count: value.rows.length }])), findings } + sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, { observed: value.error === null, count: value.rows.length }])), findings } } diff --git a/backend/src/modules/mcp/integration.test.ts b/backend/src/modules/mcp/integration.test.ts index f1d3a7a1..a358ee02 100644 --- a/backend/src/modules/mcp/integration.test.ts +++ b/backend/src/modules/mcp/integration.test.ts @@ -1282,6 +1282,7 @@ try { preflightFixtures = Object.fromEntries(Object.values(preflightPaths("ipv4")).map(path => ["/rest" + path, []])) preflightFixtures["/rest/ip/route"] = [1, 2].map(index => ({ ".id": `*p${index}`, "dst-address": "192.0.2.0/24", gateway: `192.0.2.${index}`, "immediate-gw": `192.0.2.${index}%same-gre`, "routing-table": "main", distance: "10", active: "true", disabled: "false" })) preflightFixtures["/rest/ip/settings"] = { "rp-filter": "strict", "ipv4-multipath-hash-policy": "l3" } + preflightFixtures["/rest/routing/table"] = [{ ".id": "*main", name: "main", fib: "true" }] preflightFixtures["/rest/ip/firewall/connection/tracking"] = { enabled: "auto" } preflightFixtures["/rest/ip/firewall/filter"] = [{ ".id": "*ft", action: "fasttrack-connection", disabled: "false" }] preflightFixtures["/rest/ip/firewall/nat"] = [{ ".id": "*nat", action: "masquerade", disabled: "false" }] @@ -1293,6 +1294,11 @@ try { assert.equal(preflight.configurationObserved, true); assert.equal(preflight.networkValidation, "not-performed"); assert.equal(catalogCalls, 10) for (const code of ["SHARED_EGRESS_INTERFACE", "STRICT_RPF_REVIEW", "FASTTRACK_REVIEW", "NAT_AFFINITY_REVIEW"]) assert.ok(preflight.findings.some(item => item.code === code), code) await assert.rejects(invokeMcpTool(preflightPrincipal, "mm_ecmp_preflight_get", { ...preflightInput, serverId: 2 })); assert.equal(catalogCalls, 10) + preflightFixtures["/rest/routing/table"] = [{ ".id": "*main", name: "main", fib: "" }] + const noFibPreflight = await invokeMcpTool(preflightPrincipal, "mm_ecmp_preflight_get", preflightInput) as typeof preflight + assert.equal(noFibPreflight.configurationObserved, false) + assert.ok(noFibPreflight.findings.some(item => item.code === "FIB_TABLE_UNCONFIRMED")) + preflightFixtures["/rest/routing/table"] = [{ ".id": "*main", name: "main", fib: "true" }] preflightUnavailable = "/rest/ip/firewall/nat" const unknownPreflight = await invokeMcpTool(preflightPrincipal, "mm_ecmp_preflight_get", preflightInput) as typeof preflight assert.equal(unknownPreflight.configurationObserved, false); assert.ok(unknownPreflight.findings.some(item => item.code === "OBSERVATION_UNAVAILABLE")); assert.equal(writes, preflightWritesBefore) @@ -2224,7 +2230,7 @@ try { await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,1,2,'fixture-gre','peer','ipv4','main',12,0,100,200,now(),now(),true,'fixture')", [observationId]) await attestRoutingFixture(observationId) try { - type OspfAnalysis = { ranking: { currentCost: number; proposedCost: number; observationId: string; peerId: number }[]; serverIds: number[]; changes: number; proposalOnly: boolean; executionAvailable: boolean } + type OspfAnalysis = { ranking: { currentCost: number; proposedCost: number; observationId: string; bandwidthObservationId: string | null; peerId: number }[]; serverIds: number[]; changes: number; proposalOnly: boolean; executionAvailable: boolean } const applicationPreviewPolicy={name:"OSPF application preview",mode:"observer",expiresAt:new Date(Date.now()+1800000).toISOString(),permissions:["mm:network:read"],scope:{serverIds:[1,2],operationIds:["routing.ospf.application.preview"],allowGlobal:false}} const applicationPreviewAccess=await createMcpConnection(mcpConnectionCreateSchema.parse(applicationPreviewPolicy),actor),applicationPreviewPrincipal=await authenticateMcpToken(applicationPreviewAccess.token) const isolatedPreviewAccess=await createMcpConnection(mcpConnectionCreateSchema.parse({...applicationPreviewPolicy,name:"OSPF source-only preview",scope:{...applicationPreviewPolicy.scope,serverIds:[1]}}),actor),isolatedPreviewPrincipal=await authenticateMcpToken(isolatedPreviewAccess.token) @@ -2388,10 +2394,14 @@ try { })}finally{countCatalogCalls=false} assert.equal((await (await import("./routing-observations.js")).readRoutingObservations([1, 2]))[0].sourceVerified, false, "Disable/re-enable must invalidate previous source attestation") await attestRoutingFixture(observationId) + const pingOnlyId = randomUUID(), otherPeerInterfaceId = randomUUID() + const insertPingObservation = (id: string, peerInterface: string) => pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,ping_at,context_verified,source) VALUES($1,1,2,'fixture-gre',$2,'ipv4','main',11,0,now(),true,'fixture')", [id, peerInterface]) + await insertPingObservation(pingOnlyId, "peer") + await attestRoutingFixture(pingOnlyId) const proposal = await invokeMcpTool(analysisPrincipal, "mm_ospf_optimizer_analyze", { serverId: 1 }) as OspfAnalysis assert.deepEqual(proposal.serverIds, [1, 2]); assert.equal(proposal.changes, 1) assert.equal(proposal.ranking[0].currentCost, 30); assert.equal(proposal.ranking[0].proposedCost, 10) - assert.equal(proposal.ranking[0].observationId, observationId); assert.equal(proposal.ranking[0].peerId, 2) + assert.equal(proposal.ranking[0].observationId, pingOnlyId); assert.equal(proposal.ranking[0].bandwidthObservationId, observationId); assert.equal(proposal.ranking[0].peerId, 2) assert.equal(proposal.proposalOnly, true); assert.equal(proposal.executionAvailable, false) assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans WHERE connection_id=$1", [analysisAccess.connection.id])).rows[0].n, 0) assert.equal(ospfAnalysisDispatches, 0) @@ -2405,6 +2415,9 @@ try { await verifyRoutingObservationChecks(observationEvidence.observationChecks) await assert.rejects(verifyRoutingObservationChecks(observationEvidence.observationChecks.map((check: Record) => ({ ...check, validUntil: new Date(Date.now() - 1).toISOString() })))) await approvePlan(prepared.planId, actor, true) + await insertPingObservation(otherPeerInterfaceId, "another-peer-interface") + await attestRoutingFixture(otherPeerInterfaceId) + await verifyRoutingObservationChecks(observationEvidence.observationChecks) await pool.query("UPDATE mcp_routing_observations SET created_at=now()-interval '1 minute' WHERE id=$1", [observationId]) await attestRoutingFixture(observationId) await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,1,2,'fixture-gre','peer','ipv4','main',12,50,100,200,now(),now(),true,'fixture')", [badObservationId]) @@ -2422,7 +2435,7 @@ try { await pool.query("UPDATE mcp_routing_observations SET loss_pct=0,context_verified=false WHERE id=$1", [badObservationId]) await attestRoutingFixture(badObservationId) await assert.rejects(invokeMcpTool(analysisPrincipal, "mm_ospf_optimizer_analyze", { serverId: 1 })) - await pool.query("DELETE FROM mcp_routing_observations WHERE id=$1", [badObservationId]) + await pool.query("DELETE FROM mcp_routing_observations WHERE id=ANY($1::uuid[])", [[badObservationId, pingOnlyId, otherPeerInterfaceId]]) await pool.query("UPDATE mcp_routing_observations SET loss_pct=5 WHERE id=$1", [observationId]) await attestRoutingFixture(observationId) await assert.rejects(verifyRoutingObservationChecks(observationEvidence.observationChecks)) @@ -2589,9 +2602,10 @@ try { const nativeRequests = ospfAnalysisNativeRequests - requestsBeforeApply assert.ok(nativeRequests <= 160, `One OSPF cost PATCH caused ${nativeRequests} native requests; full graph polling must not run inside each catalog callback`) console.log(`✓ OSPF dispatch read budget: ${nativeRequests} native requests across three owners, including one PATCH; no nested full graph polling`) - await pool.query("UPDATE mcp_plans SET operation_version=8 WHERE id=$1", [prepared.planId]) + const { rows: [version] } = await pool.query<{ operation_version: number }>("SELECT operation_version FROM mcp_plans WHERE id=$1", [prepared.planId]) + await pool.query("UPDATE mcp_plans SET operation_version=$2 WHERE id=$1", [prepared.planId, version.operation_version - 1]) try { await assert.rejects(invokeMcpTool(graphPrincipal, "mm_operation_recovery_plan", { operationId: operation.id }), (error: { code?: string }) => error.code === "PLAN_EXPIRED") } - finally { await pool.query("UPDATE mcp_plans SET operation_version=9 WHERE id=$1", [prepared.planId]) } + finally { await pool.query("UPDATE mcp_plans SET operation_version=$2 WHERE id=$1", [prepared.planId, version.operation_version]) } const oldInverse = await invokeMcpTool(graphPrincipal, "mm_operation_recovery_plan", { operationId: operation.id }) as { planId: string } await approvePlan(oldInverse.planId, actor, true) await pool.query("UPDATE mcp_plans SET operation_version=1 WHERE id=$1", [oldInverse.planId]) @@ -2755,8 +2769,8 @@ try { await approvePlan(brokenEcmp.planId, actor, true) loseEcmpGateway = true const brokenOperation = await applyPlan(ecmpPrincipal, brokenEcmp.planId, randomUUID()) - await awaitOperation(brokenOperation.id, "partial"); assert.equal(ecmpWrites, 6) - await applyPlan(ecmpPrincipal, brokenEcmp.planId, randomUUID()); await delay(30); assert.equal(ecmpWrites, 6) + await awaitOperation(brokenOperation.id, "partial"); assert.equal(ecmpWrites, 5) + await applyPlan(ecmpPrincipal, brokenEcmp.planId, randomUUID()); await delay(30); assert.equal(ecmpWrites, 5) loseEcmpGateway = false; fixtureEcmpRoutes = structuredClone(ecmpBaseline) const tableProtection = randomUUID() await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2::jsonb,'integration')", [tableProtection, JSON.stringify({ kind: "routing_table", id: "*main", serverId: 1, protection: "deny" })]) @@ -2786,7 +2800,13 @@ try { assert.equal(state, "applied"); assert.equal(writes, 1); assert.equal(settings["rp-filter"], "loose") assert.equal((await pool.query("SELECT state FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows[0].state, "verified") console.log("✓ Human approval, write-ahead intent, single idempotent write and read-back verification") - const stale = await invokeMcpTool(principal, "mm_ip_settings_update_plan", { serverId: 1, fields: { "rp-filter": "strict" } }) as { planId: string } + const { detectMcpDrift } = await import("./workers.js") + const driftCount = async () => (await pool.query("SELECT count(*)::int AS n FROM mcp_drift WHERE operation_id=$1", [operation.id])).rows[0].n + const readsBeforeMatchingDrift = reconcileSettingsReads + await detectMcpDrift() + assert(reconcileSettingsReads > readsBeforeMatchingDrift) + assert.equal(await driftCount(), 0) + const stale = await invokeMcpTool(principal, "mm_ip_settings_update_plan", { serverId: 1, fields: { "rp-filter": "loose" } }) as { planId: string } await approvePlan(stale.planId, actor, true) settings["rp-filter"] = "no" const conflict = await applyPlan(principal, stale.planId, "conflict-key") @@ -2797,6 +2817,14 @@ try { } assert.equal(state, "rejected"); assert.equal(writes, 1) console.log("✓ Changed snapshots reject execution before contacting the write endpoint") + const readsBeforeConfirmedDrift = reconcileSettingsReads + await detectMcpDrift() + assert(reconcileSettingsReads > readsBeforeConfirmedDrift) + assert.equal(await driftCount(), 1) + await detectMcpDrift() + assert.equal(await driftCount(), 1) + assert.equal(writes, 1) + console.log("✓ Drift detection reads the bound RouterOS source, records one confirmed incident and never replays writes") const expiring = await invokeMcpTool(principal, "mm_ip_settings_update_plan", { serverId: 1, fields: { "rp-filter": "loose" } }) as { planId: string } const sealedEvidence = (await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [expiring.planId])).rows[0].evidence const expiringEvidence = JSON.parse(await decryptSecret(sealedEvidence.sealed)) @@ -2855,6 +2883,7 @@ try { process.env.MCP_PUBLIC_URL = endpoint app = await buildApp({ startScheduler: false }) await app.listen({ port: appPort, host: "127.0.0.1" }) + await (await import("../../test/users-profile-permissions.js")).verifyUsersProfilePermissions(app) await (await import("../../test/mcp-backup-download.js")).verifyMcpBackupDownload(actor,app) await (await import("../../test/mcp-database-backup.js")).verifyMcpDatabaseBackup(actor,app) const fixtureScheduler = await import("../../services/scheduler-running.js") @@ -2935,7 +2964,9 @@ try { } finally { release(); await following.catch(() => undefined) } } finally { pool.query = original; await follower.close() } } - await (await import("./logs-follow-sdk-cancellation-acceptance.js")).verifyLogsFollowSdkCancellation(app, endpoint, actor) + const logsSdkAcceptance = await import("./logs-follow-sdk-cancellation-acceptance.js") + await logsSdkAcceptance.verifyLogsFollowSdkCancellation(app, endpoint, actor) + await logsSdkAcceptance.verifyLogsScopedSdkReads(app, endpoint, actor) const stdio = new Client({ name: "stdio-integration", version: "1.0.0" }) try { await stdio.connect(new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", fileURLToPath(new URL("../../scripts/mcp-stdio.ts", import.meta.url))], env: { MCP_URL: endpoint, MCP_TOKEN: rotated.token }, stderr: "pipe" })) @@ -3035,12 +3066,6 @@ try { assert.equal((await tokenRequest(refreshPayload)).statusCode, 400) await assert.rejects(authenticateMcpToken(refreshed.json().access_token)) console.log("✓ OAuth consent, PKCE, resource binding, one-use code, refresh rotation and reuse-family revocation") - const { detectMcpDrift } = await import("./workers.js") - await detectMcpDrift() - assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_drift WHERE operation_id=$1", [operation.id])).rows[0].n, 1) - await detectMcpDrift() - assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_drift WHERE operation_id=$1", [operation.id])).rows[0].n, 1) - console.log("✓ Drift detection records a single open incident without replaying writes") const currentPrincipal = await authenticateMcpToken(rotated.token) await assert.rejects(invokeMcpTool(currentPrincipal, "mm_operation_recovery_plan", { operationId: operation.id }), error => error instanceof Error && "code" in error && error.code === "STATE_CONFLICT") const beforeRecovery = settings["rp-filter"] 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 ff06e890..dc0a5f91 100644 --- a/backend/src/modules/mcp/logs-follow-sdk-cancellation-acceptance.ts +++ b/backend/src/modules/mcp/logs-follow-sdk-cancellation-acceptance.ts @@ -1,13 +1,121 @@ import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" +import { createHash, randomUUID } from "node:crypto" import type { IncomingMessage, ServerResponse } from "node:http" import { setTimeout as delay } from "node:timers/promises" import type { FastifyInstance } from "fastify" import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" +import { SignJWT } from "jose" +import type { LogEntry } from "@mmapp/contracts" import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp" import { pool } from "../../db/index.js" +import { env } from "../../config.js" import type { AuthUser } from "../../lib/permissions.js" -import { createMcpConnection } from "./store.js" +import { createMcpConnection, setConnectionStatus } from "./store.js" + +export async function verifyLogsScopedSdkReads(app: FastifyInstance, endpoint: string, actor: AuthUser) { + const historicalId = 93, hiddenId = 94, marker = `mcp-scoped-logs-${randomUUID()}` + const from = "2001-01-01T00:00:00Z", to = "2001-01-01T01:00:00Z" + const client = new Client({ name: "logs-scoped-sdk-reads", version: "1.0.0" }) + try { + await pool.query("INSERT INTO servers(id,name,host,enabled,username,password) VALUES($1,$2,'127.0.0.1',false,'fixture','fixture')", [historicalId, marker]) + const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ + name: marker, mode: "observer", permissions: ["mm:servers:read"], + expiresAt: new Date(Math.min(actor.expiresAt ?? Infinity, Date.now() + 600_000)).toISOString(), + scope: { serverIds: [1, historicalId], operationIds: ["logs.get", "logs.context", "logs.summary", "logs.export"], allowGlobal: false }, + }), actor) + const { rows } = await pool.query<{ id: string; serverId: number; index: string }>(` + INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation) + SELECT server_id,$1,'127.0.0.1',n::text,'00:30:00',now(),$2::timestamptz+(30+n)*interval '1 minute',ARRAY['system'],'info', + CASE WHEN server_id=$4 THEN $1 || ' hidden-row' ELSE $1 END,0 + FROM (SELECT 1 AS server_id,n FROM generate_series(-12,12) AS n UNION ALL SELECT $3,0 UNION ALL SELECT $4,0) AS fixture + RETURNING id::text,server_id AS "serverId",router_id AS "index"`, [marker, from, historicalId, hiddenId]) + await pool.query("INSERT INTO router_log_collection_state(server_id,server_name,host) SELECT id,$1,'127.0.0.1' FROM unnest($2::int[]) AS id", [marker, [historicalId, hiddenId]]) + assert.equal((await pool.query("DELETE FROM servers WHERE id=$1 AND name=$2", [historicalId, marker])).rowCount, 1) + await client.connect(new StreamableHTTPClientTransport(new URL(endpoint), { + requestInit: { headers: { Authorization: `Bearer ${access.token}` } }, + })) + const idAt = (serverId: number, index: number) => { + const row = rows.find(item => item.serverId === serverId && Number(item.index) === index) + assert.ok(row) + return row.id + } + const call = (name: string, args: Record) => client.callTool({ name, arguments: args }) + const resultOf = async (name: string, args: Record) => { + const response = await call(name, args) + assert.ok(!response.isError) + return (response.structuredContent as { result: T }).result + } + const id = idAt(1, 0) + assert.equal((await resultOf("mm_logs_get", { serverId: 1, id })).id, id) + type LogContext = { entry: LogEntry; before: LogEntry[]; after: LogEntry[] } + const context = await resultOf("mm_logs_context_get", { serverId: 1, id }) + assert.equal(context.entry.id, id) + assert.deepEqual(context.before.map(row => row.id), Array.from({ length: 10 }, (_, i) => idAt(1, i - 10))) + assert.deepEqual(context.after.map(row => row.id), Array.from({ length: 10 }, (_, i) => idAt(1, i + 1))) + const historical = { serverId: historicalId, id: idAt(historicalId, 0) } + assert.equal((await resultOf("mm_logs_get", historical)).serverId, historicalId) + const historicalContext = await resultOf("mm_logs_context_get", historical) + assert.equal(historicalContext.entry.id, historical.id) + assert.deepEqual([...historicalContext.before, ...historicalContext.after], []) + for (const name of ["mm_logs_get", "mm_logs_context_get"]) { + const foreignId = idAt(hiddenId, 0) + const foreign = await call(name, { serverId: 1, id: foreignId }) + assert.equal(foreign.isError, true) + assert.match(JSON.stringify(foreign), /NOT_FOUND/) + assert.doesNotMatch(JSON.stringify(foreign), new RegExp(marker)) + const outOfScope = await call(name, { serverId: hiddenId, id: foreignId }) + assert.equal(outOfScope.isError, true) + assert.match(JSON.stringify(outOfScope), /SERVER_DENIED/) + } + const summary = await resultOf<{ total: number; levels: { info: number }; states: { serverId: number }[] }>( + "mm_logs_summary_get", { from, to, q: marker }, + ) + assert.equal(summary.total, 26) + assert.equal(summary.levels.info, 26) + assert.ok(summary.states.some(state => state.serverId === historicalId)) + assert.ok(summary.states.every(state => state.serverId === 1 || state.serverId === historicalId)) + assert.ok(!("settings" in summary)) + console.log("✓ MCP logs SDK: scoped entries, same-server context and historical summary") + + const portalJwt = (subject: string, permissions: string[]) => + new SignJWT({ sub: subject, apps: ["mm"], permissions }) + .setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("5m") + .sign(new TextEncoder().encode(env.jwtSecret)) + const ownerToken = await portalJwt(actor.id, actor.permissions) + const otherToken = await portalJwt(`${actor.id}-other`, ["mm:mcp:read", "mm:servers:read"]) + const restrictedToken = await portalJwt(actor.id, ["mm:mcp:read"]) + type Export = { artifactId: string; downloadPath: string; expiresInSeconds: number; rows: number; sha256: string } + const exportLogs = (format: "csv" | "json") => resultOf("mm_logs_export", { from, to, q: marker, format }) + const download = (url: string, token?: string) => + app.inject({ url, headers: token ? { authorization: `Bearer ${token}` } : {} }) + const csv = await exportLogs("csv") + assert.equal(csv.rows, 26) + assert.equal(csv.expiresInSeconds, 900) + assert.equal(csv.downloadPath, `/api/mcp/artifacts/${csv.artifactId}`) + const response = await download(csv.downloadPath, ownerToken) + assert.equal(response.statusCode, 200) + assert.equal(response.headers["cache-control"], "private, no-store") + assert.match(String(response.headers["content-type"]), /^text\/csv/) + assert.equal(createHash("sha256").update(response.rawPayload).digest("hex"), csv.sha256) + assert.ok(!response.body.includes("hidden-row")) + assert.equal((await download(csv.downloadPath)).statusCode, 401) + assert.equal((await download(csv.downloadPath, access.token)).statusCode, 401) + assert.equal((await download(csv.downloadPath, otherToken)).statusCode, 404) + assert.equal((await download(csv.downloadPath, restrictedToken)).statusCode, 403) + + const json = await exportLogs("json") + await pool.query("UPDATE mcp_artifacts SET expires_at=now()-interval '1 second' WHERE id=$1 AND connection_id=$2", [json.artifactId, access.connection.id]) + assert.equal((await download(json.downloadPath, ownerToken)).statusCode, 404) + await setConnectionStatus(access.connection.id, "revoked", actor) + assert.equal((await download(csv.downloadPath, ownerToken)).statusCode, 403) + console.log("✓ MCP logs export: CSV owner download, scope, non-cacheable response, expiry and revocation") + } finally { + await client.close() + await pool.query("DELETE FROM router_log_entries WHERE message=ANY($1::text[])", [[marker, `${marker} hidden-row`]]) + await pool.query("DELETE FROM router_log_collection_state WHERE server_id=ANY($1::int[]) AND server_name=$2", [[historicalId, hiddenId], marker]) + await pool.query("DELETE FROM servers WHERE id=$1 AND name=$2", [historicalId, marker]) + } +} export async function verifyLogsFollowSdkCancellation(app: FastifyInstance, endpoint: string, actor: AuthUser) { const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ diff --git a/backend/src/modules/mcp/native-conservation-acceptance.ts b/backend/src/modules/mcp/native-conservation-acceptance.ts index 96372d19..c2c6a5b2 100644 --- a/backend/src/modules/mcp/native-conservation-acceptance.ts +++ b/backend/src/modules/mcp/native-conservation-acceptance.ts @@ -6,6 +6,7 @@ import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp" import { pool } from "../../db/index.js" import type { AuthUser } from "../../lib/permissions.js" import { managedComment } from "../../managed-markers.js" +import { updateServer } from "../servers/service/servers-service.js" import type { ChangeEvidence } from "./action-types.js" import { findAction } from "./actions.js" import { readRosSnapshot } from "./resources.js" @@ -18,13 +19,16 @@ import { authenticateMcpToken, createMcpConnection, setConnectionStatus } from " export async function acceptNativeConservation(actor: AuthUser) { const serverId = 198 const filterPath = "/ip/firewall/filter", rulePath = "/routing/rule" + const routePaths = ["/ip/route", "/ipv6/route"], settingsPaths = ["/ip/settings", "/ipv6/settings"] const base = () => ["a", "b", "c"].map(id => ({ ".id": `*${id}`, chain: "fixture", action: "accept", comment: id, disabled: "true" })) const ruleBase = () => ["a", "b", "c"].map(id => ({ ".id": `*${id}`, action: "lookup", table: "main", comment: id, disabled: "true" })) let catalogs: Record[]> = {}, mode = "normal", writes = 0, next = 0, polls = 0 let connectionId: string | undefined let afterIntent: (() => Promise) | undefined, afterVerified: (() => Promise) | undefined + let afterRouteRead: (() => Promise) | undefined const query = pool.query.bind(pool) const commands: Array<{ method: string; path: string }> = [] + const readPaths = new Set() const reset = () => { mode = "normal" catalogs = { [filterPath]: base(), [rulePath]: ruleBase(), "/routing/table": [{ ".id": "*main", name: "main", fib: "true", disabled: "false" }], "/ip/settings": [{ "rp-filter": "no", "ipv4-multipath-hash-policy": "l3" }], "/ip/route": [] } @@ -33,8 +37,18 @@ export async function acceptNativeConservation(actor: AuthUser) { const router = createServer(async (req, res) => { const path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, "")) res.setHeader("content-type", "application/json") - if (req.method === "GET") { polls++; res.end(JSON.stringify(catalogs[path] ?? [])); return } - const parent = [filterPath, rulePath, "/ip/route", "/ip/settings"].find(value => path === value || path.startsWith(`${value}/`)) + if (req.method === "GET") { + polls++; readPaths.add(path) + const body = JSON.stringify(catalogs[path] ?? []) + if (path === "/ip/route" && afterRouteRead) { + const hook = afterRouteRead + afterRouteRead = undefined + await hook() + } + res.end(body) + return + } + const parent = [filterPath, rulePath, ...routePaths, ...settingsPaths].find(value => path === value || path.startsWith(`${value}/`)) if (!parent || !["PUT", "PATCH", "DELETE"].includes(req.method ?? "")) { res.statusCode = 404; res.end("{}"); return } let text = ""; for await (const chunk of req) text += chunk const fields = text ? JSON.parse(text) as Record : {} @@ -42,11 +56,11 @@ export async function acceptNativeConservation(actor: AuthUser) { writes++; commands.push({ method: req.method!, path }) if (mode !== "ignored") { if (req.method === "PUT") rows.push({ ".id": id, ...fields }) - if (req.method === "PATCH") Object.assign(parent === "/ip/settings" ? rows[0] : rows.find(row => row[".id"] === id)!, fields) + if (req.method === "PATCH") Object.assign(settingsPaths.includes(parent) ? rows[0] : rows.find(row => row[".id"] === id)!, fields) if (req.method === "DELETE") catalogs[parent] = rows.filter(row => row[".id"] !== id) } const after = catalogs[parent] - if (parent === "/ip/route") for (const row of after) row.active = Number(row.distance) === Math.min(...after.map(item => Number(item.distance))) ? "true" : "false" + if (routePaths.includes(parent)) for (const row of after) row.active = Number(row.distance) === Math.min(...after.map(item => Number(item.distance))) ? "true" : "false" if (mode === "two-new") after.push({ ".id": `*extra${next}`, ...fields }) if (mode === "collateral-update") after.find(row => row[".id"] === "*b")!.comment = "external" if (mode === "collateral-remove") catalogs[parent] = after.filter(row => row[".id"] !== "*b") @@ -80,7 +94,7 @@ export async function acceptNativeConservation(actor: AuthUser) { try { assert.equal((await query("SELECT count(*)::int AS n FROM servers WHERE id=$1", [serverId])).rows[0].n, 0) await query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'native-conservation','127.0.0.1',$2,false,'fixture-only')", [serverId, (router.address() as { port: number }).port]) - const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Native conservation", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write"], scope: { serverIds: [serverId], operationIds: ["ros.ip_filter.create", "ros.ip_filter.update", "ros.ip_filter.delete", "ros.routing_rule.update", "ros.ipv4_route.update", "ros.ip_settings.update", "routing.ecmp.update", "routing.rpf.update", "operations.recover", "operations.reconcile"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 1000 } }), actor) + const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Native conservation", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write"], scope: { serverIds: [serverId], operationIds: ["ros.ip_filter.create", "ros.ip_filter.update", "ros.ip_filter.delete", "ros.routing_rule.update", "ros.ipv4_route.update", "ros.ip_settings.update", "routing.ecmp.update", "routing.rpf.update", "operations.recover", "operations.reconcile", "operations.read"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 1000 } }), actor) connectionId = access.connection.id let principal = await authenticateMcpToken(access.token) const prepare = (action: "create" | "update" | "delete", resource = "ip_filter") => invokeMcpTool(principal, `mm_${resource}_${action}_plan`, { serverId, ...(action === "create" ? {} : { id: "*a" }), fields: action === "create" ? { chain: "fixture", action: "accept", comment: "new", disabled: true } : action === "update" ? { comment: "changed" } : {} }) as Promise<{ planId: string }> @@ -204,13 +218,233 @@ export async function acceptNativeConservation(actor: AuthUser) { const routeBaseline = [0, 1].map(index => ({ ".id": `*route${index}`, "dst-address": "192.0.2.0/24", "routing-table": "main", gateway: `198.18.0.${index + 1}`, "immediate-gw": `198.18.0.${index + 1}%fixture`, scope: "0", distance: String((index + 1) * 10), active: index === 0 ? "true" : "false", disabled: "false", dynamic: "false", comment: managedComment("conservation") })) catalogs["/ip/route"] = structuredClone(routeBaseline) const originalSettings = structuredClone(catalogs["/ip/settings"]) - const equal = await invokeMcpTool(principal, "mm_ecmp_plan", { serverId, family: "ipv4", routeIds: ["*route0", "*route1"], distance: 10, hashPolicy: "l4" }) as { planId: string } + const ecmpArgs = { serverId, family: "ipv4", routeIds: ["*route0", "*route1"], distance: 10, hashPolicy: "l4" } + const beforeEcmp = writes + const observer = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "ECMP interface-only preflight", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [serverId], operationIds: ["routing.ecmp.preflight.read"], allowGlobal: false } }), actor) + try { + const reader = await authenticateMcpToken(observer.token) + const { distance, hashPolicy, ...preflightInput } = ecmpArgs + catalogs["/ip/firewall/connection/tracking"] = [{ enabled: "auto" }] + catalogs["/ip/route"] = routeBaseline.map((row, index) => ({ ...row, gateway: `gre${index}`, "immediate-gw": `gre${index}` })) + catalogs["/interface"] = [0, 1].map(index => ({ ".id": `*gre${index}`, name: `gre${index}` })) + const before = polls + const result = await invokeMcpTool(reader, "mm_ecmp_preflight_get", preflightInput) as { configurationObserved: boolean; networkValidation: string; sources: { interfaces: { count: number } } } + assert.equal(result.configurationObserved, true) + assert.equal(result.networkValidation, "not-performed") + assert.equal(result.sources.interfaces.count, 2) + assert.equal(polls - before, 11) + assert.equal(writes, beforeEcmp) + } finally { + await query("DELETE FROM mcp_connections WHERE id=$1", [observer.connection.id]) + catalogs["/ip/route"] = structuredClone(routeBaseline) + } + catalogs["/ip/route"] = routeBaseline.map((row, index) => ({ ...row, gateway: `gre${index}`, "immediate-gw": `gre${index}` })) + const namedHopPlan = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string; changes: unknown } + assert.ok(!JSON.stringify(namedHopPlan.changes).includes("nextHopInterface")) + const inventory = structuredClone(catalogs["/interface"]) + afterIntent = async () => { catalogs["/interface"][0][".id"] = "*replacement" } + const replacedHop = await apply(namedHopPlan.planId) + assert.equal(afterIntent, undefined) + assert.equal(replacedHop.state, "rejected") + assert.equal(replacedHop.error_code, "STATE_CONFLICT") + assert.equal(writes, beforeEcmp) + catalogs["/interface"] = inventory + catalogs["/ip/route"] = structuredClone(routeBaseline) + console.log("ECMP interface-only next-hop: same-name replacement after intent refused before PATCH; native identity stays private") + afterRouteRead = async () => { catalogs["/ip/route"][1].distance = "25" } + await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" }) + assert.equal(afterRouteRead, undefined) + catalogs["/ip/route"] = structuredClone(routeBaseline) + afterRouteRead = async () => { + await query("UPDATE servers SET password='source-aba-fixture' WHERE id=$1", [serverId]) + await query("UPDATE servers SET password='fixture-only' WHERE id=$1", [serverId]) + } + await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" }) + assert.equal(afterRouteRead, undefined) + assert.equal(writes, beforeEcmp) + catalogs["/ip/route"][1]["immediate-gw"] = routeBaseline[0]["immediate-gw"] + await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" }) + assert.equal(writes, beforeEcmp) + catalogs["/ip/route"] = structuredClone(routeBaseline) + const fibTable = catalogs["/routing/table"] + for (const rows of [ + [{ ...fibTable[0], fib: "false" }], + [{ ...fibTable[0], fib: "" }], + [{ ".id": "*main", name: "main" }], + [{ ...fibTable[0], disabled: "true" }], + [{ ...fibTable[0], invalid: "true" }], + [...fibTable, { ...fibTable[0], ".id": "*duplicate" }], + ]) { + catalogs["/routing/table"] = rows + await assert.rejects(invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs), { code: "STATE_CONFLICT" }) + assert.equal(writes, beforeEcmp) + } + catalogs["/routing/table"] = fibTable + const staleFib = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string } + await approvePlan(staleFib.planId, actor, true) + catalogs["/routing/table"] = [{ ...fibTable[0], fib: "false" }] + assert.equal((await wait((await applyPlan(principal, staleFib.planId, randomUUID())).id)).state, "rejected") + assert.equal(writes, beforeEcmp) + catalogs["/routing/table"] = fibTable + console.log("ECMP FIB SQL/HTTP: inactive, unconfirmed and ambiguous tables refused; drift rejected before native write") + const equal = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string } const beforeEqual: number = writes, equalResult = await apply(equal.planId) assert.equal(equalResult.state, "applied"); assert.equal(writes, beforeEqual + 3) assert.equal(catalogs["/ip/settings"][0]["ipv4-multipath-hash-policy"], "l4") const unequal = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: equalResult.operation.id }) as { planId: string } assert.equal((await apply(unequal.planId)).state, "applied"); assert.equal(writes, beforeEqual + 6) assert.deepEqual(catalogs["/ip/route"], routeBaseline); assert.deepEqual(catalogs["/ip/settings"], originalSettings) + const lateEcmp = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string } + afterIntent = async () => { catalogs["/ip/route"][1]["immediate-gw"] = "" } + const lateEcmpResult = await apply(lateEcmp.planId) + assert.equal(afterIntent, undefined) + assert.equal(lateEcmpResult.state, "rejected") + assert.equal(lateEcmpResult.error_code, "STATE_CONFLICT") + assert.equal(writes, beforeEqual + 6) + catalogs["/ip/route"] = structuredClone(routeBaseline) + const sourceChanged = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string } + let lateRouteReads = 0 + const rotateDuringHealthRead = async () => { + if (++lateRouteReads === 1) { + // The prefix reads the configuration first; the next GET checks the live next-hop. + afterRouteRead = rotateDuringHealthRead + return + } + await assert.rejects(updateServer(serverId, { password: "concurrent-update-fixture" }), { statusCode: 409 }) + await query("UPDATE servers SET password='health-source-aba-fixture' WHERE id=$1", [serverId]) + await query("UPDATE servers SET password='fixture-only' WHERE id=$1", [serverId]) + } + afterIntent = async () => { afterRouteRead = rotateDuringHealthRead } + const staleHealthSource = await apply(sourceChanged.planId) + assert.equal(lateRouteReads, 2) + assert.equal(staleHealthSource.state, "rejected") + assert.equal(staleHealthSource.error_code, "STATE_CONFLICT") + assert.equal(writes, beforeEqual + 6) + const beforeNextStep = await invokeMcpTool(principal, "mm_ecmp_plan", ecmpArgs) as { planId: string } + afterVerified = async () => { catalogs["/ip/route"][1]["immediate-gw"] = "" } + const partialEcmp = await apply(beforeNextStep.planId) + assert.equal(partialEcmp.state, "partial") + assert.equal(writes, beforeEqual + 7) + for (const [result, confirmedWrites] of [[lateEcmpResult, 0], [partialEcmp, 1]] as const) { + const stored = await invokeMcpTool(principal, "mm_operation_get", { operationId: result.operation.id }) as { + state: string; result: { failedStepDispatched: boolean; confirmedWrites: number; needsReview: boolean } + } + assert.equal(stored.state, result.state) + assert.equal(stored.result.failedStepDispatched, false) + assert.equal(stored.result.confirmedWrites, confirmedWrites) + assert.equal(stored.result.needsReview, confirmedWrites > 0) + const { rows: [stop] } = await pool.query("SELECT payload FROM mcp_audit WHERE action='operation.stop' AND payload->>'operationId'=$1", [result.operation.id]) + assert.equal(stop.payload.failedStepDispatched, false) + } + catalogs["/ip/route"] = structuredClone(routeBaseline) + const collapsed = await invokeMcpTool(principal, "mm_ecmp_plan", { ...ecmpArgs, hashPolicy: undefined }) as { planId: string } + afterVerified = async () => { + afterVerified = async () => { catalogs["/ip/route"][1]["immediate-gw"] = routeBaseline[0]["immediate-gw"] } + } + const collapsedResult = await apply(collapsed.planId) + assert.equal(collapsedResult.state, "partial") + assert.equal(collapsedResult.verification, "failed") + assert.equal(writes, beforeEqual + 9) + assert.equal(afterVerified, undefined) + catalogs["/ip/route"] = structuredClone(routeBaseline) + console.log("ECMP SQL/HTTP: shared adjacency refuses preparation and post-write collapse fails verification") + const v6Baseline = routeBaseline.map((row, index) => ({ + ...row, "dst-address": "2001:db8::/64", gateway: `2001:db8:1::${index + 1}`, + "immediate-gw": `fe80::${index + 1}%fixture`, + })) + catalogs["/ipv6/route"] = structuredClone(v6Baseline) + const v6Settings = [{ "multipath-hash-policy": "l3" }] + catalogs["/ipv6/settings"] = structuredClone(v6Settings) + const v6Mangle = "/ipv6/firewall/mangle" + catalogs[v6Mangle] = [{ ".id": "*v6mark", action: "mark-routing", disabled: "false", "new-routing-mark": "NEVER_NATIVE_POLICY_MARK", bytes: "0", packets: "0" }] + const v6Args = { ...ecmpArgs, family: "ipv6" } + readPaths.clear() + for (const [path, field] of [[v6Mangle, "new-routing-mark"], [rulePath, "min-prefix"]]) { + const stale = await invokeMcpTool(principal, "mm_ecmp_plan", v6Args) as { planId: string; changes: { snapshots: Record[] } } + const policy = stale.changes.snapshots.find(snapshot => snapshot.resource === "routing_policy:ipv6_mangle") + assert.deepEqual(policy, { serverId, resource: "routing_policy:ipv6_mangle", rowCount: 1 }) + assert.ok(!JSON.stringify(stale).includes("NEVER_NATIVE_POLICY_MARK")) + await approvePlan(stale.planId, actor, true) + const original = catalogs[path], before = writes + catalogs[path] = [{ ...original[0], [field]: "17" }, ...original.slice(1)] + assert.equal((await wait((await applyPlan(principal, stale.planId, randomUUID())).id)).state, "rejected") + assert.equal(writes, before) + catalogs[path] = original + } + const v6Plan = await invokeMcpTool(principal, "mm_ecmp_plan", v6Args) as { planId: string } + catalogs[v6Mangle][0].bytes = "1500" + catalogs[v6Mangle][0].packets = "1" + const v6Result = await apply(v6Plan.planId) + assert.equal(v6Result.state, "applied") + assert.ok(catalogs["/ipv6/route"].every(row => row.active === "true" && row.distance === "10")) + assert.equal(catalogs["/ipv6/settings"][0]["multipath-hash-policy"], "l4") + const inverseArgs = { operationId: v6Result.operation.id } + catalogs[v6Mangle][0]["new-routing-mark"] = "external-mark" + await assert.rejects(invokeMcpTool(principal, "mm_operation_recovery_plan", inverseArgs), { code: "STATE_CONFLICT" }) + catalogs[v6Mangle][0]["new-routing-mark"] = "NEVER_NATIVE_POLICY_MARK" + const v6Inverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseArgs) as { planId: string } + assert.ok(!JSON.stringify(v6Inverse).includes("NEVER_NATIVE_POLICY_MARK")) + assert.equal((await apply(v6Inverse.planId)).state, "applied") + assert.deepEqual(catalogs["/ipv6/route"], v6Baseline) + assert.deepEqual(catalogs["/ipv6/settings"], v6Settings) + assert.ok(![...readPaths].some(path => path === "/ip/settings" || path.startsWith("/ip/firewall/"))) + console.log("ECMP IPv6 SQL/HTTP: family-specific reads, policy drift refusal, route/hash apply and inverse") + const rpfArgs = { serverId, mode: "strict", acknowledgeAsymmetry: true } + const manglePath = "/ip/firewall/mangle" + const beforeRpf = writes, beforeIpv6 = polls + await assert.rejects(invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, family: "ipv6" }), { code: "UNSUPPORTED_CAPABILITY" }) + assert.equal(polls, beforeIpv6) + catalogs[rulePath][0].disabled = "false" + await assert.rejects(invokeMcpTool(principal, "mm_rpf_plan", rpfArgs), { code: "STATE_CONFLICT" }) + const strictSettings = { serverId, fields: { "rp-filter": "strict", "ipv4-multipath-hash-policy": "l4" } } + await assert.rejects(invokeMcpTool(principal, "mm_ip_settings_update_plan", strictSettings), { code: "STATE_CONFLICT" }) + catalogs[rulePath][0].disabled = "true" + catalogs[manglePath] = [{ ".id": "*mark", chain: "prerouting", action: "mark-routing", disabled: "false", "new-routing-mark": "fixture-mark" }] + await assert.rejects(invokeMcpTool(principal, "mm_rpf_plan", rpfArgs), { code: "STATE_CONFLICT" }) + const loose = await invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, mode: "loose" }) as { changes: { warnings: string[] } } + assert.ok(loose.changes.warnings.some(warning => warning.includes("policy routing"))) + catalogs[manglePath][0].disabled = "true" + const strict = await invokeMcpTool(principal, "mm_rpf_plan", rpfArgs) as { planId: string } + const genericStrict = await invokeMcpTool(principal, "mm_ip_settings_update_plan", strictSettings) as { planId: string } + await approvePlan(strict.planId, actor, true) + await approvePlan(genericStrict.planId, actor, true) + catalogs[manglePath][0]["new-routing-mark"] = "changed-after-approval" + const staleRpf = await applyPlan(principal, strict.planId, randomUUID()) + assert.equal((await wait(staleRpf.id)).state, "rejected") + assert.equal((await wait((await applyPlan(principal, genericStrict.planId, randomUUID())).id)).state, "rejected") + assert.equal(writes, beforeRpf) + catalogs[manglePath] = [] + catalogs["/ip/settings"][0]["rp-filter"] = "loose" + const disableRpf = await invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, mode: "disabled" }) as { planId: string } + assert.equal((await apply(disableRpf.planId)).state, "applied") + assert.deepEqual(catalogs["/ip/settings"], originalSettings) + catalogs["/ip/settings"][0]["rp-filter"] = "strict" + const leaveStrict = await invokeMcpTool(principal, "mm_rpf_plan", { ...rpfArgs, mode: "no" }) as { planId: string } + const leftStrict = await apply(leaveStrict.planId) + assert.equal(leftStrict.state, "applied") + const inverseRpfArgs = { operationId: leftStrict.operation.id } + const beforeInverseRpf = writes + catalogs[manglePath] = [{ ".id": "*late-mark", action: "mark-routing", disabled: "false" }] + await assert.rejects(invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs), { code: "STATE_CONFLICT" }) + catalogs[manglePath] = [] + const legacyRpf = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs) as { planId: string } + const legacyRpfEvidence = await evidence(legacyRpf.planId) + legacyRpfEvidence.snapshots = legacyRpfEvidence.snapshots.filter(snapshot => !snapshot.resource.startsWith("routing_policy:")) + await replace(legacyRpf.planId, legacyRpfEvidence) + await approvePlan(legacyRpf.planId, actor, true) + await assert.rejects(applyPlan(principal, legacyRpf.planId, randomUUID()), { code: "PLAN_EXPIRED" }) + const staleInverseRpf = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs) as { planId: string } + await approvePlan(staleInverseRpf.planId, actor, true) + catalogs[manglePath] = [{ ".id": "*late-mark", action: "mark-routing", disabled: "false" }] + assert.equal((await wait((await applyPlan(principal, staleInverseRpf.planId, randomUUID())).id)).state, "rejected") + assert.equal(writes, beforeInverseRpf) + catalogs[manglePath] = [] + const safeInverseRpf = await invokeMcpTool(principal, "mm_operation_recovery_plan", inverseRpfArgs) as { planId: string } + assert.equal((await apply(safeInverseRpf.planId)).state, "applied") + assert.equal(catalogs["/ip/settings"][0]["rp-filter"], "strict") + catalogs["/ip/settings"] = structuredClone(originalSettings) + console.log("RPF inverse SQL/HTTP: active policy, legacy plans and post-approval drift refused; exact strict restoration under verified policy") + console.log("RPF SQL/HTTP: explicit IPv6 refusal, strict policy admission and drift, loose warning, disabled alias") for (const [tool, args, path] of [ ["mm_rpf_plan", { serverId, mode: "loose", acknowledgeAsymmetry: true }, "/ip/settings"], ["mm_ip_settings_update_plan", { serverId, fields: { "rp-filter": "loose" } }, "/ip/settings"], @@ -229,6 +463,43 @@ export async function acceptNativeConservation(actor: AuthUser) { const booleanResult = await apply(booleanPlan.planId); assert.equal(booleanResult.state, "applied") const booleanInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: booleanResult.operation.id }) as { planId: string } assert.equal((await apply(booleanInverse.planId)).state, "applied"); assert.deepEqual(catalogs[filterPath], booleanBaseline) + catalogs["/ip/route"] = routeBaseline.map((row, index) => ({ ...row, gateway: `gre${index}`, "immediate-gw": `gre${index}` })) + const stableHopPlan = await invokeMcpTool(principal, "mm_ecmp_plan", { ...ecmpArgs, hashPolicy: undefined }) as { planId: string } + const beforeStableHops = writes + const stableHops = await apply(stableHopPlan.planId) + assert.equal(stableHops.state, "applied") + assert.equal(writes, beforeStableHops + 2) + assert.ok(catalogs["/ip/route"].every(row => row.distance === "10" && row.active === "true")) + console.log("ECMP interface-only next-hop: unchanged interface IDs verified before both writes and after apply") + const namedHopInverseArgs = { operationId: stableHops.operation.id } + const plansBeforeReplacement = (await query("SELECT count(*)::int AS count FROM mcp_plans WHERE connection_id=$1", [connectionId])).rows[0].count + catalogs["/interface"][0][".id"] = "*replaced-before-preparation" + await assert.rejects(invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs), { code: "STATE_CONFLICT" }) + assert.equal((await query("SELECT count(*)::int AS count FROM mcp_plans WHERE connection_id=$1", [connectionId])).rows[0].count, plansBeforeReplacement) + catalogs["/interface"][0][".id"] = "*gre0" + const oldHopInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs) as { planId: string } + const oldHopEvidence = await evidence(oldHopInverse.planId) + delete oldHopEvidence.healthChecks + await replace(oldHopInverse.planId, oldHopEvidence) + const beforeInverseHops = writes + const oldHopResult = await apply(oldHopInverse.planId) + assert.equal(oldHopResult.state, "rejected") + assert.equal(oldHopResult.error_code, "PLAN_EXPIRED") + assert.equal(writes, beforeInverseHops) + const staleHopInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs) as { planId: string; changes: unknown } + assert.ok(!JSON.stringify(staleHopInverse.changes).includes("nextHopInterface")) + afterIntent = async () => { catalogs["/interface"][0][".id"] = "*inverse-replacement" } + const refusedInverse = await apply(staleHopInverse.planId) + assert.equal(afterIntent, undefined) + assert.equal(refusedInverse.state, "rejected") + assert.equal(refusedInverse.error_code, "STATE_CONFLICT") + assert.equal(writes, beforeInverseHops) + catalogs["/interface"][0][".id"] = "*gre0" + const safeHopInverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", namedHopInverseArgs) as { planId: string } + assert.equal((await apply(safeHopInverse.planId)).state, "applied") + assert.equal(writes, beforeInverseHops + 2) + assert.deepEqual(catalogs["/ip/route"].map(row => row.distance), ["10", "20"]) + console.log("ECMP inverse: replaced interface refused before PATCH; intact interface restores unequal costs") console.log("Native multi-catalog inverse SQL/HTTP: two route PATCHes plus singleton hash policy, exact reverse prefix, RPF/raw singleton, numeric zero, false and empty-comment originals preserved") console.log("Native conservation SQL/HTTP: C/U/D and safe inverses; assigned-ID, membership, field and order failures never VERIFIED/replayed; honest two-step prefix stops before second intent; late source/dependency/protection/policy fence zero writes; stale operation revision refused. No CHR/opaque-field/forwarding claim.") } finally { diff --git a/backend/src/modules/mcp/operation-revisions.test.ts b/backend/src/modules/mcp/operation-revisions.test.ts index 75873a24..de793d1c 100644 --- a/backend/src/modules/mcp/operation-revisions.test.ts +++ b/backend/src/modules/mcp/operation-revisions.test.ts @@ -32,8 +32,8 @@ assert.equal(rebuildReconcile.operation.effect, "read") assert.equal(rebuildReconcile.operation.scope, "global") assert.deepEqual(rebuildReconcile.operation.requiredPermissions, ["mm:traffic:write"]) assert.throws(() => rebuildReconcile.schema.parse({ jobId: "caller" })) -assert.equal(action.operation.version, 9) -assert.equal(findMcpOperation(action.operation.id)?.version, 9) +assert.equal(action.operation.version, 10) +assert.equal(findMcpOperation(action.operation.id)?.version, 10) assert.throws(() => action.schema.parse({ serverId: 1, expectedServerProof: "forged-source" })) assert.throws(() => action.schema.parse({ serverId: 1, version: 1 })) assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4") @@ -53,13 +53,13 @@ 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", 8], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 4], ["mm_firewall_rule_update_plan", 2]] as const) { +for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 5], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 8], ["mm_ecmp_plan", 7], ["mm_rpf_plan", 4], ["mm_ip_settings_update_plan", 3], ["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) } assert.throws(() => findAction("mm_routing_measurement_plan").schema.parse({ measurement: { srcServerId: 1, dstServerId: 2, srcInterface: "gre", dstInterface: "gre", family: "ipv4", expectedEndpoints: [] } })) -console.log("MCP operation revisions: OSPF v9 and template order revisions agree; clients cannot inject revision or private source proof") +console.log("MCP operation revisions: OSPF v10 and template order revisions agree; clients cannot inject revision or private source proof") assert.equal(findAction("mm_ipsec_peer_create_plan").operation.version, 5) assert.equal(findAction("mm_certificate_update_plan").operation.version, 4) diff --git a/backend/src/modules/mcp/ordinary-operation-retirement.owned.test.ts b/backend/src/modules/mcp/ordinary-operation-retirement.owned.test.ts index 87cce853..fe3b1ea3 100644 --- a/backend/src/modules/mcp/ordinary-operation-retirement.owned.test.ts +++ b/backend/src/modules/mcp/ordinary-operation-retirement.owned.test.ts @@ -9,9 +9,10 @@ await prepareOwnedBandwidthState() const {pool,closePool}=await import("../../db/index.js") const {applySqlMigrations}=await import("../../db/migrate.js") const {mcpConnectionCreateSchema}=await import("@mmapp/contracts/mcp") -const {createMcpConnection,authenticateMcpToken}=await import("./store.js") +const {createMcpConnection,authenticateMcpToken,getMcpSettings}=await import("./store.js") const {invokeMcpTool,approvePlan,applyPlan}=await import("./runtime.js") const {OrdinaryRetirementCommitUnknown,retireOrdinaryMcpOperations}=await import("./ordinary-operation-retirement.js") +const {PARTITION_WRITER_ADVISORY_KEY}=await import("../../services/partition-write-transaction.js") const {tickMcpWorkers}=await import("./workers.js") const {pauseSchedulerJobsAndWait,resumeSchedulerJobs}=await import("../../services/scheduler-running.js") const {Client}=await import("pg") @@ -20,9 +21,58 @@ const {touchGeoipRunMeta}=await import("../../services/geoip-settings.js") const {touchBackupScheduleRunMeta}=await import("../../services/backup-service.js") const errorIs=(code:string,status:number)=>(error:unknown)=>error instanceof McpError&&error.code===code&&error.statusCode===status +async function assertWorkerUsesCommittedRetention() { + const actorId=randomUUID() + const original=await getMcpSettings() + await pool.query("UPDATE mcp_settings SET requests_retention_days=1,audit_retention_days=30 WHERE id=1") + const settingsWriter=await pool.connect() + let tick:Promise|undefined,settled=false,transactionOpen=false + try{ + await pool.query(`INSERT INTO mcp_requests(id,actor_id,tool,status,created_at) VALUES + ($1,$5,'protected','completed',now()-interval '2 days'), + ($2,$5,'active','running',now()-interval '33 days'), + ($3,$5,'recent','completed',now()-interval '1 hour'), + ($4,$5,'expired','completed',now()-interval '33 days')`, + [randomUUID(),randomUUID(),randomUUID(),randomUUID(),actorId]) + await pool.query(`INSERT INTO mcp_audit(actor_id,action,created_at) VALUES + ($1,'protected',now()-interval '31 days'),($1,'recent',now()-interval '1 hour'), + ($1,'expired',now()-interval '183 days')`,[actorId]) + await settingsWriter.query("BEGIN") + transactionOpen=true + const {pid}=(await settingsWriter.query("SELECT pg_backend_pid() AS pid")).rows[0] + await settingsWriter.query("UPDATE mcp_settings SET requests_retention_days=30,audit_retention_days=180 WHERE id=1") + tick=tickMcpWorkers().finally(()=>{settled=true}) + void tick.catch(()=>{}) + let waiting=false + for(const deadline=performance.now()+3000;!settled&&!waiting&&performance.now(){}) + await pool.query("DELETE FROM mcp_requests WHERE actor_id=$1",[actorId]) + await pool.query("DELETE FROM mcp_audit WHERE actor_id=$1",[actorId]) + await pool.query("UPDATE mcp_settings SET requests_retention_days=$1,audit_retention_days=$2 WHERE id=1",[original.requestsRetentionDays,original.auditRetentionDays]) + } + console.log("Owned MCP retention: worker waits for committed policy, preserves protected and running rows, and deletes expired rows") +} + try { await applySqlMigrations(pool) await pauseSchedulerJobsAndWait() + await assertWorkerUsesCommittedRetention() await pool.query("UPDATE public.mcp_settings SET enabled=true WHERE id=1") const serverId=Number((await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned logs retention','127.0.0.1',false) RETURNING id")).rows[0].id) async function access(){ @@ -31,12 +81,18 @@ try { issuedAt:Date.now()-1000,expiresAt:Date.now()+300000} const issued=await createMcpConnection(mcpConnectionCreateSchema.parse({name:actor.name,mode:"approval", permissions:actor.permissions,expiresAt:new Date(actor.expiresAt).toISOString(), - scope:{serverIds:[serverId],operationIds:["logs.settings.update","traffic.settings.update","uptime.settings.update", + scope:{serverIds:[serverId],operationIds:["logs.settings.read","logs.settings.update","traffic.settings.update","uptime.settings.update", "servers.api-ping.settings.update","internet-path.settings.update","geoip.settings.update","backups.schedule.update","operations.read","changes.apply"],allowGlobal:true}, limits:{callsPerMinute:100,mutationsPerHour:20}}),actor) return {actor,principal:await authenticateMcpToken(issued.token)} } const {actor,principal}=await access(),foreign=(await access()).principal + const initialLogs=await invokeMcpTool(principal,"mm_logs_settings_get",{}) + assert.deepEqual(initialLogs,{enabled:false,intervalSec:10,retentionDays:14}) + const shorterLogs=await invokeMcpTool(principal,"mm_logs_settings_update_plan",{settings:{retentionDays:7}}) as {state:string;changes:{warnings:string[]}} + assert.equal(shorterLogs.state,"awaiting-approval") + assert.match(shorterLogs.changes.warnings.join(" "),/с 14 до 7 суток.*полученные более 7 суток назад/) + assert.deepEqual(await invokeMcpTool(principal,"mm_logs_settings_get",{}),initialLogs) async function operation(retentionDays:number,ageMs:number,tool="mm_logs_settings_update_plan", settings:Record={retentionDays}){ const plan=await invokeMcpTool(principal,tool,{settings}) as {planId:string;state:string} @@ -79,6 +135,38 @@ try { LEFT JOIN mcp_operation_steps s ON s.operation_id=i.id LEFT JOIN mcp_operation_checkpoints k ON k.operation_id=i.id LEFT JOIN mcp_operation_replay_tombstones t ON t.operation_id=i.id LEFT JOIN mcp_ordinary_retirement_claims c ON c.operation_id=i.id ORDER BY i.id`,[ids])).rows + const beforeFence=await projection() + const exclusive=await pool.connect() + let blockedAttempt:ReturnType|undefined + try{ + await exclusive.query("SELECT pg_advisory_lock($1::bigint)",[PARTITION_WRITER_ADVISORY_KEY]) + await exclusive.query("BEGIN") + blockedAttempt=retireOrdinaryMcpOperations() + void blockedAttempt.catch(()=>{}) + let waiting=false + for(const deadline=Date.now()+3000;!waiting&&Date.now()0);assert.equal(blocked.retired,0);assert.equal(blocked.automaticReplay,false) + assert.deepEqual(await projection(),beforeFence) + }finally{ + try{await blockedAttempt}finally{ + try{ + await exclusive.query("ROLLBACK") + assert.equal((await exclusive.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked",[PARTITION_WRITER_ADVISORY_KEY])).rows[0].unlocked,true) + }finally{exclusive.release(true)} + } + } const originalQuery=Client.prototype.query let commits=0 try{ diff --git a/backend/src/modules/mcp/ordinary-operation-retirement.ts b/backend/src/modules/mcp/ordinary-operation-retirement.ts index eb8a7b29..b0c2b229 100644 --- a/backend/src/modules/mcp/ordinary-operation-retirement.ts +++ b/backend/src/modules/mcp/ordinary-operation-retirement.ts @@ -5,6 +5,7 @@ import { backupScheduleSettingsDtoSchema, putBackupScheduleSettingsSchema } from import { z } from "zod" import { connectProbeStorage } from "../../services/server-probe-storage-connection.js" import type { ProbeStorageSql } from "../../services/server-probe-journal-maintenance.js" +import { admitPartitionWriter } from "../../services/partition-write-transaction.js" import { sharedDatabaseGate } from "../../services/shared-database-gate.js" import { operationIdempotencyDigest } from "./operation-replay-tombstones.js" import { decryptSecretExisting, fingerprint, privateFingerprinterExisting, redact } from "./security.js" @@ -110,11 +111,13 @@ const SOURCE = `SELECT to_jsonb(o) AS operation,to_jsonb(p) AS plan,to_jsonb(s) AND octet_length(to_jsonb(o)::text)+octet_length(to_jsonb(p)::text)+octet_length(to_jsonb(s)::text)+octet_length(to_jsonb(k)::text)<=1048576 FOR UPDATE OF o,p,s,k NOWAIT` -async function transaction(deadlineAt:number, work:(sql:ProbeStorageSql)=>Promise):Promise { +async function transaction(deadlineAt:number, mode:"scan"|"write", work:(sql:ProbeStorageSql)=>Promise):Promise { const sql = await connectProbeStorage({deadlineAt}) let committing=false,committed=false try { await sql.query("BEGIN") + // Preserve the one-second lock budget and admit before any target row or operation lock. + if(mode==="write")await admitPartitionWriter(sql,{deadlineAt:Math.min(deadlineAt,Date.now()+1000)}) await sql.query("SELECT set_config('TimeZone','UTC',true),set_config('statement_timeout','5000ms',true),set_config('lock_timeout','1000ms',true)") const result=await work(sql) committing=true @@ -173,13 +176,13 @@ async function prepare(sql:ProbeStorageSql, operationId:string, connectionId:str async function retire(operationId:string,connectionId:string,deadlineAt:number) { const token=randomUUID() - await transaction(deadlineAt,async sql=>{ + await transaction(deadlineAt,"write",async sql=>{ const source=await prepare(sql,operationId,connectionId) const claim=await sql.query(`INSERT INTO public.mcp_ordinary_retirement_claims(operation_id,plan_id,connection_id,source_proof,claim_token) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING operation_id`,[operationId,source.planId,connectionId,source.proof,token]) requireThat(claim.rows.length===1&&claim.rows[0].operation_id===operationId) }) - await transaction(deadlineAt,async sql=>{ + await transaction(deadlineAt,"write",async sql=>{ const claim=(await sql.query(`SELECT source_proof,plan_id FROM public.mcp_ordinary_retirement_claims WHERE operation_id=$1 AND connection_id=$2 AND claim_token=$3 AND claim_xid<>pg_current_xact_id()`,[operationId,connectionId,token])).rows requireThat(claim.length===1) @@ -203,7 +206,7 @@ let cursor:{finishedAt:string;operationId:string}|undefined export function retireOrdinaryMcpOperations() { return sharedDatabaseGate.request(async()=>{ const deadlineAt=Date.now()+15000 - const rows=await transaction(deadlineAt,async sql=>(await sql.query(CANDIDATES,[cursor?.finishedAt??null,cursor?.operationId??null])).rows) + const rows=await transaction(deadlineAt,"scan",async sql=>(await sql.query(CANDIDATES,[cursor?.finishedAt??null,cursor?.operationId??null])).rows) requireThat(rows.length<=8) let scanned=0,retired=0 for(const row of rows){ diff --git a/backend/src/modules/mcp/recovery-actions.ts b/backend/src/modules/mcp/recovery-actions.ts index b9420048..c1327726 100644 --- a/backend/src/modules/mcp/recovery-actions.ts +++ b/backend/src/modules/mcp/recovery-actions.ts @@ -14,6 +14,8 @@ import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-so 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" +import { readRpfDependencies } from "./routing-controls.js" +import { verifyHealthChecks } from "./routing-health-checks.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]) @@ -109,6 +111,7 @@ export function registerRecoveryActions(register: typeof add) { 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] + const warnings = [`Обратный план операции ${row.id}; не является повтором исходного действия.`, "Восстановление удалённых объектов и сервисов требует их собственного механизма ревизий.", "Сохраняются инварианты Full OSPF/Up BFD и привязка соседств к устройствам, VRF, адресам и интерфейсам; достижимость восстановленной сетевой конфигурации проверяется контрольными пробами."] for (const checkpoint of checkpoints) { const original = evidence.steps[checkpoint.step_index] if (!original || original.kind !== "ros" || original.step.action === "delete") throw new McpError("UNSUPPORTED_CAPABILITY", "Удалённый объект может иметь новые ID и зависимости; используйте проверенную ревизию", 409) @@ -148,8 +151,32 @@ export function registerRecoveryActions(register: typeof add) { if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== rosSnapshotVersion(dependency.resource)) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409) snapshots.set(key, current) } + for (const change of steps) { + if (change.kind !== "ros" || change.step.resource !== "ip_settings" || !change.step.fields["rp-filter"]) continue + const settings = snapshots.get(`${change.step.serverId}:ip_settings`) + const rpf = await readRpfDependencies(change.step.serverId, change.step.fields["rp-filter"], settings?.sourceProof) + for (const snapshot of rpf.snapshots) { + const key = `${snapshot.serverId}:${snapshot.resource}`, previous = snapshots.get(key) + if (previous && (previous.fingerprint !== snapshot.fingerprint || previous.sourceProof !== snapshot.sourceProof)) { + throw new McpError("STATE_CONFLICT", "Зависимость RPF изменилась при подготовке восстановления", 409) + } + snapshots.set(key, snapshot) + } + warnings.push(...rpf.warnings) + } objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId }))) - return { target: { scope: "servers", serverIds: [...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))] }, objects, snapshots: [...snapshots.values()], steps, ospfOwnerGraph: evidence.ospfOwnerGraph, healthChecks: evidence.healthChecks?.filter(check => check.field === "state" || check.field === "bfd-up"), requiresHuman: true, warnings: [`Обратный план операции ${row.id}; не является повтором исходного действия.`, "Восстановление удалённых объектов и сервисов требует их собственного механизма ревизий.", "Сохраняются инварианты Full OSPF/Up BFD и привязка соседств к устройствам, VRF, адресам и интерфейсам; достижимость восстановленной сетевой конфигурации проверяется контрольными пробами."] } + const healthChecks = evidence.healthChecks?.filter(check => check.field === "state" || check.field === "bfd-up" || check.field === "resolved") + const currentSnapshots = [...snapshots.values()] + const serverIds = [...new Set(currentSnapshots.map(snapshot => snapshot.serverId))] + const nextHopChecks = healthChecks?.filter(check => check.field === "resolved") + if (nextHopChecks?.length) await verifyHealthChecks({ healthChecks: nextHopChecks, snapshots: currentSnapshots }, serverIds, false, async () => { + if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Подготовка обратного плана отменена", 409) + const current = await effectiveMcpConnection(context.principal) + if (fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) { + throw new McpError("RESET_REQUIRED", "Допуск восстановления изменился", 409) + } + }) + return { target: { scope: "servers", serverIds }, objects, snapshots: currentSnapshots, steps, ospfOwnerGraph: evidence.ospfOwnerGraph, healthChecks, requiresHuman: true, warnings } }, }) } diff --git a/backend/src/modules/mcp/resources.ts b/backend/src/modules/mcp/resources.ts index 2488e653..22bf9e78 100644 --- a/backend/src/modules/mcp/resources.ts +++ b/backend/src/modules/mcp/resources.ts @@ -6,7 +6,7 @@ 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 { McpError, fingerprint, privateFingerprinter, 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" @@ -50,7 +50,7 @@ export const rosResources: RosResourceSpec[] = [ { 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"] }, { id: "ipv4_route", path: "/ip/route", title: "Маршруты IPv4", permission: "mm:network", fields: z.object(route).strict(), create: true, remove: true, requiredOnCreate: ["dst-address"] }, { id: "ipv6_route", path: "/ipv6/route", title: "Маршруты IPv6", permission: "mm:network", fields: z.object(route).strict(), create: true, remove: true, requiredOnCreate: ["dst-address"] }, - { id: "routing_table", path: "/routing/table", title: "Таблицы маршрутизации", permission: "mm:network", fields: z.object({ name: name.optional(), fib: bool.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] }, + { id: "routing_table", path: "/routing/table", title: "Таблицы маршрутизации", permission: "mm:network", fields: z.object({ name: name.optional(), fib: bool.optional(), disabled: bool.optional() }).strict(), observeFields: ["invalid"], create: true, remove: true, requiredOnCreate: ["name"] }, { id: "routing_rule", path: "/routing/rule", title: "Правила маршрутизации", permission: "mm:network", fields: z.object({ "src-address": text.optional(), "dst-address": text.optional(), interface: text.optional(), action: z.enum(["lookup", "lookup-only-in-table", "drop", "unreachable"]).optional(), table: name.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["action"] }, { id: "vrf", path: "/ip/vrf", title: "VRF", permission: "mm:network", fields: z.object({ name: name.optional(), interfaces: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interfaces"] }, { id: "ip_settings", path: "/ip/settings", title: "RPF и IPv4 hash-policy", permission: "mm:network", fields: z.object({ "rp-filter": z.enum(["no", "loose", "strict"]).optional(), "ipv4-multipath-hash-policy": z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), create: false, remove: false }, @@ -77,8 +77,34 @@ 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 const rosSnapshotVersion = (resource: string): number => ["bridge", "bridge_port", "bridge_vlan", "routing_table"].includes(resource) ? 4 : ROS_SNAPSHOT_VERSION export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string } +const routingPolicyResources = new Set(["routing_rule", "ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"]) +const routingPolicyPrefix = "routing_policy:" +const routingPolicyCounters = new Set(["bytes", "packets"]) +export function isRoutingPolicySnapshot(resource: string) { + return resource.startsWith(routingPolicyPrefix) && routingPolicyResources.has(resource.slice(routingPolicyPrefix.length)) +} +export function readRoutingPolicySnapshot(serverId: number, resource: string, expectedSourceProof?: string) { + if (!routingPolicyResources.has(resource)) throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный каталог политики маршрутизации", 409) + return readRosSnapshot(serverId, routingPolicyPrefix + resource, undefined, expectedSourceProof) +} + +async function routingPolicyRows(resource: string, payload: unknown): Promise { + if (!Array.isArray(payload)) throw new McpError("STATE_CONFLICT", "Каталог политики маршрутизации не подтверждён", 409) + const identify = await privateFingerprinter(`mcp-routing-policy-v1:${resource}`) + const ids = new Set() + return payload.map(row => { + const id = row?.[".id"] + if (!row || typeof row !== "object" || Array.isArray(row) || typeof id !== "string" || !id.trim() || ids.has(id)) { + throw new McpError("STATE_CONFLICT", "Неоднозначные ID каталога политики маршрутизации", 409) + } + ids.add(id) + // Seal the native rule, including selectors outside the write schema. + const configuration = Object.fromEntries(Object.entries(row).filter(([name]) => resource === routingPolicyPrefix + "routing_rule" || !routingPolicyCounters.has(name))) + return { ".id": id, action: row.action ?? null, disabled: row.disabled ?? null, proof: identify(fingerprint(configuration)) } + }) +} export function resourceById(id: string): RosResourceSpec { const resource = rosResources.find(item => item.id === id) if (!resource) throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный тип сетевого объекта", 400) @@ -123,7 +149,8 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown): } export async function readRosSnapshot(serverId: number, resourceId: string, beforeRead: () => Promise = async () => {}, expectedSourceProof?: string): Promise { await beforeRead() - const resource = resourceById(resourceId), { client, server } = await routerClient(serverId) + const policy = isRoutingPolicySnapshot(resourceId) + const resource = resourceById(policy ? resourceId.slice(routingPolicyPrefix.length) : resourceId), { client, server } = await routerClient(serverId) const sourceProof = await rosSnapshotSourceProof(server) if (expectedSourceProof !== undefined && expectedSourceProof !== sourceProof) throw new McpError("STATE_CONFLICT", "Источник сетевого снимка изменился до опроса", 409) const checkSource = async () => { @@ -136,7 +163,7 @@ export async function readRosSnapshot(serverId: number, resourceId: string, befo return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" }) }) : await readRouterOsSnapshotGet({ client, path: resource.path, assertCurrent: checkSource }) await checkSource() - const rows = configurationRows(resource, payload) + const rows = policy ? await routingPolicyRows(resourceId, payload) : configurationRows(resource, payload) return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resourceId), sourceProof } } export async function rosSnapshotSourceProof(server: Awaited>["server"]): Promise { diff --git a/backend/src/modules/mcp/result-budget.test.ts b/backend/src/modules/mcp/result-budget.test.ts index 4b868998..4e0e5203 100644 --- a/backend/src/modules/mcp/result-budget.test.ts +++ b/backend/src/modules/mcp/result-budget.test.ts @@ -60,6 +60,16 @@ test("non-JSON detail cannot overwrite the already applied receipt", () => { output.result = output assert.equal(projected(accepted(output, "operation")).state, "applied") }) +test("compact receipt keeps a known pre-dispatch stop and its verified prefix", () => { + for (const [state, confirmedWrites] of [["rejected", 0], ["partial", 2]] as const) { + const result = { failedStepDispatched: false, confirmedWrites } + const output = { id, plan_id: plan, state, verification: "inconclusive", error_code: "STATE_CONFLICT" } + assert.deepEqual(projected(accepted({ ...output, result: { ...result, detail: 1n } }, "operation")), { ...output, result }) + for (const invalid of [{ ...result, confirmedWrites: -1 }, { ...result, failedStepDispatched: true }]) { + assert.deepEqual(projected(accepted({ ...output, result: { ...invalid, detail: 1n } }, "operation")), output) + } + } +}) test("prepare omission keeps real plan/state, requests review, never invents executionAvailable", () => { const result = accepted({ planId: plan, state: "approved", expiresAt: "2026-10-31T12:00:00Z", executionAvailable: true, changes: "x".repeat(MCP_CALL_RESULT_BYTES) }, "plan") assert.equal(result.isError, undefined) diff --git a/backend/src/modules/mcp/result-budget.ts b/backend/src/modules/mcp/result-budget.ts index 337de4e4..da5448ea 100644 --- a/backend/src/modules/mcp/result-budget.ts +++ b/backend/src/modules/mcp/result-budget.ts @@ -36,6 +36,13 @@ function project(output: unknown, projection: ResultProjection): Record = { id, plan_id: planId, state, verification } const errorCode = data(output, "error_code") if (errorCode === null || short(errorCode)) receipt.error_code = errorCode + const outcome = data(output, "result") + const confirmedWrites = data(outcome, "confirmedWrites") + if ((state === "partial" || state === "rejected") && data(outcome, "failedStepDispatched") === false && + typeof confirmedWrites === "number" && Number.isSafeInteger(confirmedWrites) && + (state === "partial" ? confirmedWrites > 0 : confirmedWrites === 0)) { + receipt.result = { failedStepDispatched: false, confirmedWrites } + } return receipt } const planId = data(output, "planId"), state = data(output, "state") diff --git a/backend/src/modules/mcp/ros-prefix.test.ts b/backend/src/modules/mcp/ros-prefix.test.ts index 3e023bab..9c5ba3a7 100644 --- a/backend/src/modules/mcp/ros-prefix.test.ts +++ b/backend/src/modules/mcp/ros-prefix.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict" import { createRosPrefix } from "./ros-prefix.js" import { fingerprint, McpError } from "./security.js" -import { ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep, type ConfigurationRow } from "./resources.js" +import { rosSnapshotVersion, type RosSnapshot, type RosStep, type ConfigurationRow } from "./resources.js" -const snapshot = (resource: string, rows: ConfigurationRow[], sourceProof = "a".repeat(64)): RosSnapshot => ({ serverId: 1, resource, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof }) +const snapshot = (resource: string, rows: ConfigurationRow[], sourceProof = "a".repeat(64)): RosSnapshot => ({ serverId: 1, resource, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resource), sourceProof }) const old = snapshot("ip_filter", [{ ".id": "*a", chain: "forward", action: "accept", comment: "old" }, { ".id": "*b", chain: "forward", action: "drop", comment: "keep" }]) const dependency = snapshot("routing_table", [{ ".id": "*main", name: "main", fib: "true" }]) const changed = snapshot("ip_filter", [{ ...old.rows[0], comment: "new" }, old.rows[1]]) @@ -15,6 +15,7 @@ const isFailed = (error: unknown) => error instanceof McpError && error.code === for (const invalid of [{ ...old, sourceProof: undefined }, { ...old, projectionVersion: 1 }, { ...old, projectionVersion: 2 }, { ...old, fingerprint: "forged" }]) assert.throws(() => createRosPrefix([invalid]), error => error instanceof McpError && error.code === "PLAN_EXPIRED") assert.throws(() => createRosPrefix([old, changed]), isConflict) +assert.throws(() => createRosPrefix([{ ...dependency, projectionVersion: 3 }]), { code: "PLAN_EXPIRED" }) const prefix = createRosPrefix([old, dependency, structuredClone(old)], read) await prefix.check() actual.set(old.resource, changed) diff --git a/backend/src/modules/mcp/ros-step-verification.test.ts b/backend/src/modules/mcp/ros-step-verification.test.ts index 0b109f49..70c7020f 100644 --- a/backend/src/modules/mcp/ros-step-verification.test.ts +++ b/backend/src/modules/mcp/ros-step-verification.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" -import { configurationRows, isOrderedRosResource, resourceById, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js" +import { configurationRows, isOrderedRosResource, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js" import { fingerprint } from "./security.js" -import { verifyRosStepSnapshots } from "./ros-step-verification.js" +import { proveRosDriftExpectation, verifyRosStepSnapshots } from "./ros-step-verification.js" import { readOspfTemplateFlags } from "../../services/ospf-template-flags.js" function snapshot(resource: string, rows: ConfigurationRow[]): RosSnapshot { @@ -106,3 +106,55 @@ assert.equal(raw[0].opaque, "outside-projection") assert.equal(verifyRosStepSnapshots(update, {}, { ...snapshot("ip_filter", [a]), serverId: 2 }, snapshot("ip_filter", [updated])), false) assert.equal(verifyRosStepSnapshots(update, {}, snapshot("bridge", [a]), snapshot("ip_filter", [updated])), false) console.log("ROS projected whole-catalog verifier: acknowledged assigned ID, exact membership/field conservation, 12 ordered catalogs including OSPF templates, singleton settings, schema booleans, effective OSPF unset, immutable inputs") + +const sourceProof = "a".repeat(64) +function driftSnapshot(resource: string, rows: ConfigurationRow[]): RosSnapshot { + return { ...snapshot(resource, rows), projectionVersion: rosSnapshotVersion(resource), sourceProof } +} +function expectDrift(step: RosStep, receipt: unknown, before: ConfigurationRow[], after: ConfigurationRow[]) { + const expectation = proveRosDriftExpectation(step, receipt, driftSnapshot(step.resource, before), driftSnapshot(step.resource, after)) + assert.ok(expectation) + assert.equal(expectation.sourceProof, sourceProof) + return expectation +} + +const settingsDrift = expectDrift(settings, {}, [s0], [s1]) +assert.equal(settingsDrift.nativeId, undefined) +assert.equal(settingsDrift.matches(driftSnapshot("ip_settings", [{ ...s1, "ipv4-multipath-hash-policy": "l4" }])), true) +assert.equal(settingsDrift.matches(driftSnapshot("ip_settings", [s0])), false) +for (const rows of [[], [s1, s1], [{}], [{ "rp-filter": "unknown" }], [{ "rp-filter": null }], [{ "rp-filter": ["loose"] }]]) { + assert.equal(settingsDrift.matches(driftSnapshot("ip_settings", rows)), undefined) +} +const verifiedSettings = driftSnapshot("ip_settings", [s1]) +assert.equal(settingsDrift.matches({ ...verifiedSettings, sourceProof: "b".repeat(64) }), undefined) +assert.equal(settingsDrift.matches({ ...verifiedSettings, serverId: 2 }), undefined) +assert.equal(settingsDrift.matches({ ...verifiedSettings, fingerprint: "b".repeat(64) }), undefined) +assert.equal(proveRosDriftExpectation(settings, {}, snapshot("ip_settings", [s0]), verifiedSettings), undefined, "Legacy evidence without a source/version cannot prove drift") +assert.equal(proveRosDriftExpectation(settings, {}, driftSnapshot("ip_settings", [s0]), { kind: "vrf-production-private-checkpoint-v1" }), undefined) +assert.equal(proveRosDriftExpectation(settings, {}, driftSnapshot("ip_settings", [s0]), { ...verifiedSettings, sourceProof: "b".repeat(64) }), undefined) +assert.equal(proveRosDriftExpectation(settings, undefined, driftSnapshot("ip_settings", [s0]), verifiedSettings), undefined) + +const createDrift = expectDrift(create, ack, [a, b], [a, b, c]) +assert.equal(createDrift.nativeId, "*C") +assert.equal(createDrift.matches(driftSnapshot("ip_filter", [{ ...b, comment: "unrelated" }, c])), true) +assert.equal(createDrift.matches(driftSnapshot("ip_filter", [a, b])), false) +assert.equal(createDrift.matches(driftSnapshot("ip_filter", [a, b, c, c])), undefined) +assert.equal(proveRosDriftExpectation(create, {}, driftSnapshot("ip_filter", [a, b]), driftSnapshot("ip_filter", [a, b, c])), undefined) + +const updateDrift = expectDrift(update, {}, [a, b], [updated, b]) +assert.equal(updateDrift.nativeId, "*A") +assert.equal(updateDrift.matches(driftSnapshot("ip_filter", [{ ...updated, comment: "unrelated" }, c])), true) +assert.equal(updateDrift.matches(driftSnapshot("ip_filter", [a, c])), false) +assert.equal(updateDrift.matches(driftSnapshot("ip_filter", [b, c])), false) + +const booleanDrift = expectDrift(booleanStep, {}, [a], [a]) +assert.equal(booleanDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "no" }])), true) +assert.equal(booleanDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "yes" }])), false) +assert.equal(booleanDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "" }])), undefined) + +const deleteDrift = expectDrift(remove, null, [a, b], [b]) +assert.equal(deleteDrift.nativeId, "*A") +assert.equal(deleteDrift.matches(driftSnapshot("ip_filter", [{ ...b, comment: "unrelated" }, c])), true) +assert.equal(deleteDrift.matches(driftSnapshot("ip_filter", [{ ...a, disabled: "no" }, c])), false) +assert.equal(deleteDrift.matches(driftSnapshot("ip_filter", [{ ...a, chain: "output", comment: "a different rule reused this ID" }, b])), undefined) +console.log("ROS drift proof: bound source and verified transition, scoped typed fields, unknown observations and delete ID reuse") diff --git a/backend/src/modules/mcp/ros-step-verification.ts b/backend/src/modules/mcp/ros-step-verification.ts index be74d7ca..47ddacd2 100644 --- a/backend/src/modules/mcp/ros-step-verification.ts +++ b/backend/src/modules/mcp/ros-step-verification.ts @@ -1,7 +1,8 @@ import { isDeepStrictEqual } from "node:util" +import { fingerprint } from "./security.js" 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" +import { isOrderedRosResource, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js" function booleanValue(value: unknown): unknown { if ([true, "true", "yes"].includes(value as boolean | string)) return true @@ -92,3 +93,66 @@ export function verifyRosStepSnapshots(step: RosStep, result: unknown, before: R // already prove exact membership. Ordered catalogs must also retain position. return !isOrderedRosResource(step.resource) || isDeepStrictEqual(expectedIds, [...current.keys()]) } + +const singletonRosResource = (resource: string) => resource === "ip_settings" || resource === "ipv6_settings" + +function projectedSnapshot(value: unknown, step: RosStep): value is RosSnapshot & { sourceProof: string } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const snapshot = value as RosSnapshot + if (snapshot.serverId !== step.serverId || snapshot.resource !== step.resource + || snapshot.projectionVersion !== rosSnapshotVersion(step.resource) + || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) + || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false + if (!singletonRosResource(step.resource)) return indexed(snapshot.rows) !== undefined + const row = snapshot.rows[0] + return snapshot.rows.length === 1 && !!row && typeof row === "object" && !Array.isArray(row) +} + +interface RosDriftExpectation { + nativeId: string | undefined + sourceProof: string + matches(current: unknown): boolean | undefined +} + +/** Bind the drift expectation to an acknowledged and verified transition. + * The caller distinguishes a stored JSON null (native 204) from SQL NULL. */ +export function proveRosDriftExpectation(step: RosStep, receipt: unknown, before: unknown, after: unknown): RosDriftExpectation | undefined { + if (receipt === undefined || !projectedSnapshot(before, step) || !projectedSnapshot(after, step) + || before.sourceProof !== after.sourceProof || !verifyRosStepSnapshots(step, receipt, before, after)) return undefined + const resource = resourceById(step.resource) + const nativeId = step.action === "create" ? acknowledgedCreateId(receipt) : step.id + const singleton = singletonRosResource(step.resource) + const deleted = step.action === "delete" ? before.rows.find(row => row[".id"] === nativeId) : undefined + const sourceProof = after.sourceProof + return { + nativeId, + sourceProof, + matches(current) { + if (!projectedSnapshot(current, step) || current.sourceProof !== sourceProof) return undefined + const row = singleton ? current.rows[0] : current.rows.find(item => item[".id"] === nativeId) + if (deleted) { + if (!row) return true + // A different row at a reused ID does not prove the original row returned. + return conserved(resource, deleted, row) ? false : undefined + } + if (!row) return false + let complete = true + for (const [key, expected] of Object.entries(step.fields)) { + const schema = resource.fields.shape[key] + const raw = row[key] + if (!schema || !Object.hasOwn(row, key) || raw === undefined || raw === null) { + complete = false + continue + } + const boolean = schema.safeParse(true).success + const valid = boolean ? typeof booleanValue(raw) === "boolean" + : (typeof raw === "string" || typeof raw === "number") && ( + schema.safeParse(raw).success || typeof raw === "string" && /^\d+$/.test(raw) && schema.safeParse(Number(raw)).success + ) + if (!valid) complete = false + else if (!requested(resource, { [key]: expected }, row)) return false + } + return complete ? true : undefined + }, + } +} diff --git a/backend/src/modules/mcp/routing-actions.ts b/backend/src/modules/mcp/routing-actions.ts index e05c393e..e08ff20a 100644 --- a/backend/src/modules/mcp/routing-actions.ts +++ b/backend/src/modules/mcp/routing-actions.ts @@ -1,5 +1,5 @@ import { prepareRoutingMainTransition, beginRoutingMainTransition, finishRoutingMainTransition, type RoutingMainTransitionAttempt } from "./routing-main-transition.js" -import { readRoutingObservations } from "./routing-observations.js" +import { queryRoutingObservations, readRoutingObservations } from "./routing-observations.js" import { isIP } from "node:net" import { hasPermission } from "../../lib/permissions.js" import { effectiveMcpConnection } from "./store.js" @@ -25,10 +25,45 @@ import { resolveRoutingProfileAuthority, assertRoutingProfileAuthority, type Rou import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-observation-source.js" const sid = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const historySelector = z.string().trim().min(1).max(100) +const routingHistorySchema = z.object({ + serverId: sid.optional(), srcServerId: sid.optional(), dstServerId: sid.optional(), + srcInterface: historySelector.optional(), dstInterface: historySelector.optional(), + family: z.enum(["ipv4", "ipv6"]).optional(), table: historySelector.optional(), + limit: z.number().int().min(1).max(100).default(50), + cursor: z.object({ recordedAt: z.string().datetime({ precision: 6 }), id: z.string().uuid() }).strict().optional(), +}).strict() const ospfOptimizerSchema = z.object({ serverId: sid, family: z.enum(["ipv4", "ipv6"]).default("ipv4"), table: z.string().min(1).default("main"), ecmpPaths: z.number().int().min(1).max(64).default(1), pingWeight: z.number().min(0).max(100).default(60), maxLossPct: z.number().min(0).max(100).default(1) }).strict() export { readRoutingObservations } from "./routing-observations.js" const profileTarget = (profile: z.infer) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])] +const routeAnalysisSchema = z.object({ + profile: routeAiProfileSchema, + offset: z.number().int().min(0).max(100).default(0), + limit: z.number().int().min(1).max(100).default(20), + eligibility: z.enum(["all", "eligible", "ineligible"]).default("all"), +}).strict() +const routeComparisonSchema = z.object({ profile: routeAiProfileSchema, candidateIds: z.array(z.string().min(1).max(100)).min(2).max(8) }).strict().superRefine(({ profile, candidateIds }, context) => { + const known = new Set(profile.candidates.map(candidate => candidate.id)) + if (new Set(candidateIds).size !== candidateIds.length || candidateIds.some(id => !known.has(id))) { + context.addIssue({ code: "custom", path: ["candidateIds"], message: "Укажите разные ID путей из профиля" }) + } +}) + +async function analyzeCommunityProfile(profile: z.infer, context: ActionContext) { + const ids = profileTarget(profile) + if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Профиль выходит за область доступа") + const authorize = routeContextAccess(context) + await authorize() + const ranking = rankRoutingCandidates(profile, await readRoutingObservations(ids)) + const routeContext = await readRouteCommunityContext(profile, { authorize, signal: context.signal }) + for (const row of ranking) if (routeContext.before.candidateDiagnostics.find(diagnostic => diagnostic.candidateId === row.candidate.id)?.status !== "available") { + row.eligible = false; row.reasons.push("CANDIDATE_CONTEXT_UNAVAILABLE") + } + ranking.sort((a, b) => Number(b.eligible) - Number(a.eligible)) + return { profile, ranking, ...routeContext.before, observedPath: false as const, returnPathVerified: false as const, observedAt: new Date().toISOString() } +} + async function assertCandidateEligible(profile: z.infer, candidateId: string) { const candidate = rankRoutingCandidates(profile, await readRoutingObservations(profileTarget(profile))).find(row => row.candidate.id === candidateId) if (!candidate?.eligible) throw new McpError("MEASUREMENT_UNKNOWN", "Выбранный путь не удовлетворяет свежести измерений, ограничениям и SLA: " + (candidate?.reasons.join("; ") ?? "путь отсутствует"), 409) @@ -65,21 +100,33 @@ async function filtersBefore(input: Record, afterWrite = false) export function registerRoutingActions(register: typeof add) { registerRoutingMeasurement(register) registerScopedCommunityService() - register("routing.measurements.read", "mm_routing_measurements_query", "История измерений со статусом проверки источника", "network", "mm:network:read", "read", "servers", z.object({ serverId: sid.optional() }).strict(), { + register("routing.measurements.read", "mm_routing_measurements_query", "История измерений со статусом проверки источника", "network", "mm:network:read", "read", "servers", routingHistorySchema, { target: (_input, context) => ({ scope: "servers", serverIds: context.connection.policy.scope.serverIds }), - read: async (input, context) => ({ observations: await readRoutingObservations(context.connection.policy.scope.serverIds, input.serverId as number | undefined) }), + read: async (input, context) => queryRoutingObservations(context.connection.policy.scope.serverIds, routingHistorySchema.parse(input)), }) - register("routing.analyze", "mm_route_optimizer_analyze", "Анализ профиля Community", "network", "mm:network:read", "read", "servers", z.object({ profile: routeAiProfileSchema }).strict(), { + register("routing.analyze", "mm_route_optimizer_analyze", "Анализ профиля Community", "network", "mm:network:read", "read", "servers", routeAnalysisSchema, { target: input => ({ scope: "servers", serverIds: profileTarget(routeAiProfileSchema.parse(input.profile)) }), read: async (input, context) => { - const profile = routeAiProfileSchema.parse(input.profile), ids = profileTarget(profile) - if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Профиль выходит за область доступа") - const ranking = rankRoutingCandidates(profile, await readRoutingObservations(ids)) - const routeContext = await readRouteCommunityContext(profile, { authorize: routeContextAccess(context), signal: context.signal }) - for (const row of ranking) if (routeContext.before.candidateDiagnostics.find(diagnostic => diagnostic.candidateId === row.candidate.id)?.status !== "available") { - row.eligible = false; row.reasons.push("CANDIDATE_CONTEXT_UNAVAILABLE") - } - return { profile, ranking, ...routeContext.before, observedPath: false, returnPathVerified: false, observedAt: new Date().toISOString() } + const { profile, offset, limit, eligibility } = routeAnalysisSchema.parse(input) + const { ranking, candidateDiagnostics, ...analysis } = await analyzeCommunityProfile(profile, context) + const filtered = eligibility === "all" ? ranking : ranking.filter(row => row.eligible === (eligibility === "eligible")) + const page = filtered.slice(offset, offset + limit) + const candidateIds = new Set(page.map(row => row.candidate.id)) + return { ...analysis, ranking: page, candidateDiagnostics: candidateDiagnostics.filter(row => candidateIds.has(row.candidateId)), + total: filtered.length, offset, limit, nextOffset: offset + limit < filtered.length ? offset + limit : null } + }, + }) + register("routing.compare", "mm_route_optimizer_compare", "Сравнение выбранных путей Community", "network", "mm:network:read", "read", "servers", routeComparisonSchema, { + target: input => ({ scope: "servers", serverIds: profileTarget(routeAiProfileSchema.parse(input.profile)) }), + read: async (input, context) => { + const { profile, candidateIds } = routeComparisonSchema.parse(input) + const analysis = await analyzeCommunityProfile(profile, context) + const ranking = analysis.ranking.flatMap(({ candidate, ...row }, index) => candidateIds.includes(candidate.id) + ? [{ candidateId: candidate.id, profileRank: index + 1, ...row }] + : []) + return { serverId: profile.serverId, family: profile.family, table: profile.table, community: profile.community, + ranking, bestEligibleCandidateId: ranking.find(row => row.eligible)?.candidateId ?? null, + observedPath: analysis.observedPath, returnPathVerified: analysis.returnPathVerified, observedAt: analysis.observedAt } }, }) @@ -216,7 +263,7 @@ export function registerRoutingActions(register: typeof add) { warnings: evidence.warnings, criteria: { maxLossPct: input.maxLossPct, pingWeight: input.pingWeight, ecmpPaths: input.ecmpPaths }, executionAvailable: false, proposalOnly: true, forwardingVerified: false, newApprovedPlanRequired: true } }, }) - register("routing.ospf.optimize", "mm_ospf_optimizer_plan", "Оптимизация стоимости OSPF без пересчёта при применении", "network", "mm:network:write", "write", "servers", ospfOptimizerSchema, { target: (_input, context) => ({ scope: "servers", serverIds: context.connection.policy.scope.serverIds }), prepare: buildOspfOptimizationEvidence, version: 9 }) + register("routing.ospf.optimize", "mm_ospf_optimizer_plan", "Оптимизация стоимости OSPF без пересчёта при применении", "network", "mm:network:write", "write", "servers", ospfOptimizerSchema, { target: (_input, context) => ({ scope: "servers", serverIds: context.connection.policy.scope.serverIds }), prepare: buildOspfOptimizationEvidence, version: 10 }) } async function buildOspfOptimizationEvidence(input: Record, context: ActionContext, mode: "read" | "write" = "write"): Promise { diff --git a/backend/src/modules/mcp/routing-community-context-acceptance.ts b/backend/src/modules/mcp/routing-community-context-acceptance.ts index 939a060a..4df7b60f 100644 --- a/backend/src/modules/mcp/routing-community-context-acceptance.ts +++ b/backend/src/modules/mcp/routing-community-context-acceptance.ts @@ -151,6 +151,46 @@ export async function acceptRouteCommunityContextMcp(actor: AuthUser) { const legacyComment = policyComment const withoutProbe = (({ controlProbe: _probe, ...candidate }) => candidate)(base.candidates[0]) const mainProfile = routeAiProfileSchema.parse({ ...base, table: "main", candidates: [withoutProbe, { ...withoutProbe, id: "b", gateway: "192.0.2.9", gatewayRef: { resource: "ipv4_route", id: "*r2" } }] }) + const compareAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Community comparison", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [sid], operationIds: ["routing.compare"], allowGlobal: false } }), actor) + const analyzeAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Community analysis only", mode: "observer", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read"], scope: { serverIds: [sid], operationIds: ["routing.analyze"], allowGlobal: false } }), actor) + try { + const comparePrincipal = await authenticateMcpToken(compareAccess.token) + const analyzePrincipal = await authenticateMcpToken(analyzeAccess.token) + const compare = (candidateIds: string[], profile = mainProfile) => invokeMcpTool(comparePrincipal, "mm_route_optimizer_compare", { profile, candidateIds }) + const beforeDenied = requests + await assert.rejects(invokeMcpTool(analyzePrincipal, "mm_route_optimizer_compare", { profile: mainProfile, candidateIds: ["a", "b"] })) + await assert.rejects(compare(["a", "a"])) + await assert.rejects(compare(["a", "missing"])) + await assert.rejects(compare(["a", "b"], routeAiProfileSchema.parse({ ...mainProfile, candidates: [mainProfile.candidates[0], { ...mainProfile.candidates[1], exitNodeId: sid + 1 }] }))) + assert.equal(requests, beforeDenied) + const beforeWrites = writes + const analyzed = await invokeMcpTool(analyzePrincipal, "mm_route_optimizer_analyze", { profile: mainProfile }) as { ranking: Array<{ candidate: { id: string }; eligible: boolean; score: number | null }> } + const result = await compare(["b", "a"]) as { ranking: Array<{ candidateId: string; profileRank: number; eligible: boolean; score: number | null }>; observedPath: boolean; returnPathVerified: boolean } + assert.deepEqual(result.ranking.map(({ candidateId, profileRank, eligible, score }) => ({ candidateId, profileRank, eligible, score })), analyzed.ranking.map(({ candidate, eligible, score }, index) => ({ candidateId: candidate.id, profileRank: index + 1, eligible, score }))) + assert.equal(result.observedPath, false) + assert.equal(result.returnPathVerified, false) + const pagedProfile = routeAiProfileSchema.parse({ ...mainProfile, candidates: [...mainProfile.candidates, ...Array.from({ length: 20 }, (_, index) => ({ ...mainProfile.candidates[1], id: `missing-${String(index).padStart(2, "0")}`, gatewayRef: { resource: "ipv4_route", id: `*absent${index}` } }))] }) + type AnalysisPage = { ranking: Array<{ candidate: { id: string }; eligible: boolean }>; candidateDiagnostics: Array<{ candidateId: string }>; total: number; nextOffset: number | null; currentCandidateId: string | null } + const page = (options = {}) => invokeMcpTool(analyzePrincipal, "mm_route_optimizer_analyze", { profile: pagedProfile, ...options }) as Promise + const firstPage = await page(), secondPage = await page({ offset: 20 }), full = await page({ limit: 100 }) + assert.equal(firstPage.total, 22) + assert.equal(firstPage.ranking.length, 20) + assert.equal(firstPage.candidateDiagnostics.length, 20) + assert.equal(firstPage.nextOffset, 20) + assert.equal(secondPage.nextOffset, null) + assert.deepEqual([...firstPage.ranking, ...secondPage.ranking], full.ranking) + assert.equal(secondPage.currentCandidateId, full.currentCandidateId) + const eligible = await page({ eligibility: "eligible" }), ineligible = await page({ eligibility: "ineligible", limit: 100 }) + assert.deepEqual(eligible.ranking, full.ranking.filter(row => row.eligible)) + assert.deepEqual(ineligible.ranking, full.ranking.filter(row => !row.eligible)) + await assert.rejects(page({ limit: 101 })) + assert.equal(writes, beforeWrites) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_plans WHERE connection_id=$1", [compareAccess.connection.id])).rows[0].n, 0) + assert.deepEqual(privateMarkerPaths(result), []) + console.log("✓ Route AI comparison: explicit observer scope, invalid IDs and hidden dependencies rejected before RouterOS, analysis parity and no plan or write") + } finally { + await pool.query("DELETE FROM mcp_connections WHERE id=ANY($1::uuid[])", [[compareAccess.connection.id, analyzeAccess.connection.id]]) + } for (const iface of ["wan", "hop"]) { const observationId = randomUUID() await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,$2,$2,$3,'peer','ipv4','main',1,0,100,100,now(),now(),true,'fixture')", [observationId, sid, iface]) diff --git a/backend/src/modules/mcp/routing-community-context.ts b/backend/src/modules/mcp/routing-community-context.ts index 2a103b31..495e6e00 100644 --- a/backend/src/modules/mcp/routing-community-context.ts +++ b/backend/src/modules/mcp/routing-community-context.ts @@ -17,6 +17,7 @@ import { routeContextClients } from "./routing-community-context-native.js" import { pathProofTag } from "./path-proof-purpose.js" import { assertManagementTransport, managementTransportClient, type ManagementTransportCapability } from "./management-transport.js" import { assertRoutingNetworkSharedContextRead, inspectRoutingNetworkSharedContextRead, readRoutingNetworkSharedResource, routingNetworkSharedContextPublicationFence, type RoutingNetworkSharedContextRead } from "./routing-network-native-session.js" +import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js" export function routeContextAccess(context: ActionContext) { return async () => { @@ -27,7 +28,7 @@ export function routeContextAccess(context: ActionContext) { } } export type RouteContextOptions = { authorize?: () => Promise; signal?: AbortSignal; expectedSources?: Array<{ serverId: number; proof: string }>; selectedCandidateId?: string; managementTransport?: ManagementTransportCapability } -export type OwnedRouteCommunityTarget = { nativeId: string; rawRow: NativeBgpRow; endpoint: Readonly; desiredTargetProof: (ruleBody: string) => string } +export type OwnedRouteCommunityTarget = { nativeId: string; rawRow: NativeBgpRow; endpoint: Readonly; desiredTargetProof: (ruleBody: string) => string; matchesRuleProof: (ruleBody: string, proof: unknown) => boolean } /** Fixed endpoint clients and private proofs are retained in closures. Public * results contain no credentials, native rules, arbitrary comments or SQL rows. */ @@ -234,13 +235,16 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions // untouched dependencies after its deliberate, exact body replacement. const targetRuleProof = targetRule ? sign("target-rule:" + fingerprint(stableBgpPolicyRow(targetRule))) : null const immutableTarget = targetRule ? structuredClone(targetRule) : null - const ownedTarget: OwnedRouteCommunityTarget | undefined = immutableTarget ? { - nativeId: immutableTarget[".id"], rawRow: structuredClone(immutableTarget), endpoint: Object.freeze({ ...endpoints.find(endpoint => endpoint.id === sourceId)! }), - desiredTargetProof(ruleBody) { + const ownedTarget: OwnedRouteCommunityTarget | undefined = immutableTarget ? (() => { + const desiredTargetProof = (ruleBody: string) => { if (typeof ruleBody !== "string" || Buffer.byteLength(ruleBody, "utf8") > 65536) throw new McpError("LIMIT_EXCEEDED", "Тело scoped Community фильтра превышает лимит", 409) return sign("target-rule:" + fingerprint(stableBgpPolicyRow({ ...immutableTarget, rule: ruleBody }))) - }, - } : undefined + } + return { + nativeId: immutableTarget[".id"], rawRow: structuredClone(immutableTarget), endpoint: Object.freeze({ ...endpoints.find(endpoint => endpoint.id === sourceId)! }), + desiredTargetProof, matchesRuleProof: (ruleBody: string, proof: unknown) => scopedTargetRuleMatches({ rawRow: immutableTarget, desiredTargetProof }, ruleBody, proof), + } + })() : undefined const dependencies = rawProof.map(value => { const snapshot = value as { path?: string; rows?: Record[] } return snapshot.path === "/routing/filter/rule" && snapshot.rows && targetRule ? { ...snapshot, rows: snapshot.rows.map(row => row[".id"] === targetRule[".id"] ? { ...row, rule: "[separately-sealed-owned-body]" } : row) } : value @@ -284,5 +288,5 @@ export async function readRouteCommunityContextWithSharedNativeSession(sharedCon // The acquisition ends when this function returns. Later publication fences // must use the owner rather than retain a completed transport-read permit. return { before: result.before, fence: routingNetworkSharedContextPublicationFence(sharedContext), - ownedTarget: result.ownedTarget ? { nativeId: result.ownedTarget.nativeId, rawRow: structuredClone(result.ownedTarget.rawRow) } : undefined } + ownedTarget: result.ownedTarget ? { nativeId: result.ownedTarget.nativeId, rawRow: structuredClone(result.ownedTarget.rawRow), matchesRuleProof: result.ownedTarget.matchesRuleProof } : undefined } } diff --git a/backend/src/modules/mcp/routing-community-public.ts b/backend/src/modules/mcp/routing-community-public.ts index a17a99ea..1597d0a1 100644 --- a/backend/src/modules/mcp/routing-community-public.ts +++ b/backend/src/modules/mcp/routing-community-public.ts @@ -1,4 +1,5 @@ import type { ChangeEvidence } from "./action-types.js" +import { isRoutingPolicySnapshot } from "./resources.js" const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {} const rules = (value: unknown) => Array.isArray(value) ? value.map(item => { @@ -33,8 +34,13 @@ export function publicRoutingCommunityChanges(evidence: ChangeEvidence) { /** Service recovery has the same private native bodies as its forward plan. */ export function publicMcpChangeEvidence(operationId: string, evidence: ChangeEvidence) { const scopedRecovery = operationId === "operations.recover" && evidence.steps.length === 1 && evidence.steps[0].kind === "service" && evidence.steps[0].service === "routing.community.scoped.recover" - if (operationId === "routing.profiles.save") return {...evidence,steps:evidence.steps.map(step=>{if(step.kind!=="service")return step;const {baseline:_privateBaseline,...input}=step.input;return {...step,input}})} - return operationId === "routing.community.switch" || scopedRecovery ? publicRoutingCommunityChanges(evidence) : evidence + if (operationId === "routing.profiles.save" || operationId === "routing.controller.update") return {...evidence,steps:evidence.steps.map(step=>{if(step.kind!=="service")return step;const {baseline:_privateBaseline,...input}=step.input;return {...step,input}})} + if (operationId === "routing.community.switch" || scopedRecovery) return publicRoutingCommunityChanges(evidence) + if (evidence.healthChecks?.some(check => check.nextHopInterface)) evidence = { ...evidence, healthChecks: evidence.healthChecks.map(({ nextHopInterface: _privateInterface, ...check }) => check) } + if (!evidence.snapshots.some(snapshot => isRoutingPolicySnapshot(snapshot.resource))) return evidence + return { ...evidence, snapshots: evidence.snapshots.map(snapshot => isRoutingPolicySnapshot(snapshot.resource) + ? { serverId: snapshot.serverId, resource: snapshot.resource, rowCount: snapshot.rows.length } + : snapshot) } } /** Public controller DTO is explicit. Private baseline/native proofs, seal diff --git a/backend/src/modules/mcp/routing-community-scoped-acceptance.ts b/backend/src/modules/mcp/routing-community-scoped-acceptance.ts index 50757361..eda11399 100644 --- a/backend/src/modules/mcp/routing-community-scoped-acceptance.ts +++ b/backend/src/modules/mcp/routing-community-scoped-acceptance.ts @@ -1,6 +1,6 @@ import { createRoutingReservationStore } from "./routing-reservation-store.js" import { observeCommittedScopedCheckpoint } from "../../test/committed-scoped-checkpoint-observer.js" -import { prepareRoutingControllerBaseline, advanceRoutingControllerBaseline } from "./routing-controller-baseline.js" +import { prepareRoutingControllerBaseline, advanceRoutingControllerBaseline, assertRoutingControllerBaseline } from "./routing-controller-baseline.js" import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js" import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js" import { originalEffectReportSchema, originalEffectSelectionSchema } from "./routing-original-effects.js" @@ -33,12 +33,25 @@ import { requireRecentPath } from "./path-verification.js" import { registerManagementChannel } from "./management.js" import { isSchedulerJobsPaused, pauseSchedulerJobsAndWait, resumeSchedulerJobs } from "../../services/scheduler-running.js" +type BrowserRoutingTransport = { + call(tool: string, input: Record): Promise> + approve(plan: Awaited>): Promise + verifyOperation(operation: { id: string; planId: string }): Promise +} + +type BrowserRoutingJourney = (access: { token: string; connectionId: string; serverId: number }, + run: (transport: BrowserRoutingTransport) => Promise) => Promise + /** Disposable SQL + loopback HTTP only. Does not access or configure CHR. */ -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" } = {}) { +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"; browserJourney?: BrowserRoutingJourney; bareReadbackOnly?: boolean } = {}) { + if (options.browserJourney || options.bareReadbackOnly) assert.equal(process.env.NODE_ENV, "test") 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" })) + const compileFixture = (outIface: string) => 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 initial = compileFixture(options.bareReadbackOnly ? "gre-a" : ""), foreign = compileFixture("") + const filters = ["a", "b"].map(key => ({ ".id": "*f" + key, chain: "custom-" + key, rule: key === "a" ? initial : foreign, disabled: "false", comment: managedComment("NEVER_SCOPED_COMMENT_" + key), opaque: "NEVER_SCOPED_OPAQUE" })) + const bareRule = (rule: string) => rule.replace(/^ set gw-interface "(gre-[ab])";$/gm, " set gw-interface $1;") + const patchBodies: string[] = [] let mode: "normal" | "ignored" | "lost" | "collateral" | "post-failed" | "post-unknown" = "normal", writes = 0, requests = 0 let controllerProbeUnknown = false let controllerFailoverProbeSources: string[] | undefined @@ -55,13 +68,14 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: let originalEffectCase: "transition" | "no-op" | "ambiguous" = "transition" let exposeEffectCatalogue = false const originalEffectWire: Array<{ method: string; path: string; afterPatch: boolean }> = [] - const originalEffectChanged = () => originalEffectCase === "no-op" || filters[0].rule !== initial + const originalEffectChanged = () => originalEffectCase === "no-op" || (options.bareReadbackOnly ? bareRule(filters[0].rule) !== bareRule(initial) : filters[0].rule !== initial) const effectRoutes = () => { const changed = originalEffectChanged() const row = { ".id": "*effect1", afi: "ip4", "routing-table": "tenant-a", "dst-address": "203.0.113.0/24", bgp: "true", connect: "false", static: "false", active: "true", contribution: "active", ecmp: "false", disabled: "false", filtered: "false", unreachable: "false", blackhole: "false", "bgp.communities": "65000:10", - gateway: changed ? "192.0.2.9" : "192.0.2.1", "immediate-gw": (changed ? "203.0.113.9" : "203.0.113.1") + "%wan-a" } + gateway: changed ? "192.0.2.9" : "192.0.2.1", + "immediate-gw": options.bareReadbackOnly ? changed ? "192.0.2.9%gre-b" : "192.0.2.1%gre-a" : (changed ? "203.0.113.9" : "203.0.113.1") + "%wan-a" } return originalEffectCase === "ambiguous" ? [row, { ...row, ".id": "*effect2" }] : [row] } const effectConnection = { ".id": "*ca", name: "peer-a", instance: "default", vrf: "main", "routing-table": "tenant-a", afi: "ip", @@ -80,6 +94,7 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: "/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" })), + ...(options.bareReadbackOnly ? { "/interface/gre": ["a", "b"].map(key => ({ ".id": "*e" + key, name: "gre-" + key, 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" })), ...(controllerFailoverProbeSources ? [11, 12, 13].map(index => ({ ".id": "*ad" + index, interface: "wan-a", address: "198.51.100." + index + "/32", disabled: "false", invalid: "false" })) : []), @@ -133,7 +148,7 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: 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, controllerFailoverProbeSources ? "d" : "b") - assert.equal(packet.desiredRule, filters.find(row => row[".id"] === packet.nativeId)!.rule) + assert.equal(options.bareReadbackOnly ? bareRule(packet.desiredRule) : 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) @@ -147,7 +162,8 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: 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 } if (req.method === "PATCH") { assert.deepEqual(Object.keys(args), ["rule"]); const target = filters.find(row => path === "/routing/filter/rule/" + row[".id"]); assert.ok(target) - writes++; if (mode !== "ignored") target.rule = args.rule + if (options.bareReadbackOnly) { assert.equal(target, filters[0]); patchBodies.push(args.rule) } + writes++; if (mode !== "ignored") target.rule = options.bareReadbackOnly ? bareRule(args.rule) : args.rule pendingPostCheckpoint = args.rule !== initial; pendingInversePost = args.rule === initial if (mode === "collateral") filters.find(row => row !== target)!.opaque = "changed collateral" if (mode === "lost") { req.socket.destroy(); return } @@ -199,7 +215,10 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: } while(!workerLocked) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'scoped-route','127.0.0.1',$2,false,'NEVER_SCOPED_PASSWORD')", [sid, (router.address() as { port: number }).port]) insertedServer = true - const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Scoped manual", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.path.verify", "routing.community.switch", "routing.profiles.save", "operations.reconcile", "operations.recover"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 300 } }), writer) + const operationIds = options.browserJourney + ? ["routing.path.verify", "routing.community.switch", "changes.apply", "operations.read", "operations.recover"] + : ["routing.analyze", "routing.path.verify", "routing.community.switch", "routing.profiles.save", "operations.reconcile", "operations.recover"] + const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Scoped manual", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds, allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 300 } }), writer) connectionId = access.connection.id; const principal = await authenticateMcpToken(access.token) const profile = (key: string) => routeAiProfileSchema.parse({ name: "independent " + key, maxLossPct: 5, serverId: sid, family: "ipv4", community: "65000:10", table: "tenant-" + key, probeVrf: "probe-" + key, bgpContext: { connectionId: "*c" + key, instance: "default", vrf: "main", inputChain: "custom-" + key }, candidates: [1, 9].map(value => ({ id: value === 1 ? "a" : "b", jumpHostId: sid, exitNodeId: sid, wanInterface: "wan-" + key, downstreamInterface: "hop", gateway: "192.0.2." + value, gatewayRef: { resource: "ipv4_route", id: "*r" + value }, controlProbe: { targetInterface: "target", sourceAddress: key === "a" ? "198.51.100.1" : "198.51.100.2", expectedHops: [{ serverId: sid, interface: "hop" }, { serverId: sid, interface: "target" }] } })) }) for (const key of ["a", "b"]) for (const iface of ["wan-" + key, "hop"]) { @@ -240,6 +259,68 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: assert.ok(!["pending", "running"].includes(state), "The bounded executor must reach a terminal state before assertions or fixture cleanup") return state } + if (options.browserJourney) { + exposeEffectCatalogue = true + const originalFilters = structuredClone(filters) + const origin = new URL(`http://127.0.0.1:${(router.address() as { port: number }).port}`) + const checkGateway = async (expected: string) => { + const response = await fetch(new URL("/rest/routing/route", origin), { redirect: "error", signal: AbortSignal.timeout(5000) }) + assert.equal(response.status, 200) + const rows = await response.json() + assert(Array.isArray(rows)) + assert.equal(rows.length, 1) + assert.equal(rows[0]["routing-table"], "tenant-a") + assert.equal(rows[0].active, "true") + assert.equal(rows[0].gateway, expected) + } + return await options.browserJourney({ token: access.token, connectionId: access.connection.id, serverId: sid }, async transport => { + const approveAndApply = async (prepared: Record, operationId: string) => { + const planId = prepared.planId + assert.equal(typeof planId, "string") + assert.equal(prepared.state, "awaiting-approval") + const review = await publicPlan(await loadPlan(planId as string)) + assert.equal(review.operationId, operationId) + assert.equal(review.connectionId, connectionId) + assert.equal(review.approvedBy, null) + assert(!JSON.stringify(review).includes("NEVER_")) + const unchanged = { writes, requests } + await assert.rejects(transport.call("mm_change_apply", { planId, idempotencyKey: randomUUID() }), { code: "APPROVAL_REQUIRED" }) + assert.deepEqual({ writes, requests }, unchanged) + await transport.approve(review) + assert.equal((await publicPlan(await loadPlan(planId as string))).approvedBy, writer.id) + const accepted = await transport.call("mm_change_apply", { planId, idempotencyKey: randomUUID() }) + const id = accepted.id + assert.equal(typeof id, "string") + assert.equal(await waitForOperation(id as string), "applied") + const result = await transport.call("mm_operation_get", { operationId: id }) + assert.equal(result.id, id) + assert.equal(result.state, "applied") + assert.equal(result.verification, "passed") + await transport.verifyOperation({ id: id as string, planId: planId as string }) + return { id: id as string, planId: planId as string } + } + const current = profile("a") + await checkGateway("192.0.2.1") + assert.equal((await transport.call("mm_route_path_verify", { profile: current, candidateId: "b" })).status, "passed") + const forward = await approveAndApply(await transport.call("mm_route_optimizer_plan", { profile: current, candidateId: "b" }), "routing.community.switch") + assert.equal(writes, 1) + assert.notEqual(filters[0].rule, originalFilters[0].rule) + assert.deepEqual({ ...filters[0], rule: originalFilters[0].rule }, originalFilters[0]) + assert.deepEqual(filters[1], originalFilters[1]) + await checkGateway("192.0.2.9") + const inverse = await approveAndApply(await transport.call("mm_operation_recovery_plan", { operationId: forward.id }), "operations.recover") + assert.notEqual(inverse.planId, forward.planId) + assert.notEqual(inverse.id, forward.id) + await checkGateway("192.0.2.1") + assert.deepEqual(filters, originalFilters) + assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore) + assert.equal(writes, 2) + assert.equal(postCheckpointsObserved, 1) + assert.equal(inversePostChecks, 1) + return { accepted: true, persistentAuthorityCases: 0, officialSdk: true, browserApprovals: 2, routerWrites: 2, syntheticGatewayRoundTrip: true, + originalFiltersRestored: true, externalNetwork: false, forwardingVerified: false } + }) + } const execute = async (planId: string, executingPrincipal: McpPrincipal = principal) => { const op = await applyPlan(executingPrincipal, planId, randomUUID()) return { ...op, state: await waitForOperation(op.id) } @@ -344,6 +425,63 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: } finally { await disablePersistent(profileId); controllerFailoverProbeSources = undefined; controllerFailoverReserveHealthy = true } } if (options.controllerFailoverOnly) return { ...await acceptControllerFailover(), persistentAuthorityCases: 0 } + if (options.bareReadbackOnly) { + const config = profile("a") + config.candidates = config.candidates.map(candidate => ({ ...candidate, gatewayRef: { resource: "gre", id: "*e" + candidate.id } })) + const persistent = await persistentFixture(config), context = { principal, connection: await effectiveMcpConnection(principal) } + const readProfile = async () => (await pool.query("SELECT enabled,state,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [persistent.id])).rows[0] + const enrolled = await readProfile(), grant = enrolled.grant_expires_at.toISOString() + const previous = await assertRoutingControllerBaseline(enrolled.state.baseline, persistent.id, persistent.version, config, grant, context) + const { plan, input } = await prepare("a", persistent, config, true) + assert.equal(originalEffectSelectionSchema.parse(input.originalEffectSelection).status, "prepared") + const forward = await execute(plan.planId) + assert.equal(forward.state, "applied") + const seal = await assertScopedCommunityRequest(input) + const checkpoint = await loadScopedConfigurationPacket(input, seal, { operationId: forward.id, stepIndex: 0 }) + const network = await loadScopedNetworkCheckpoint(input, seal) + assert.equal(network?.originalEffects?.status, "observed-expected") + assert.ok(checkpoint) + assert.match(checkpoint.packet.desiredRule, / set gw-interface "gre-b";/) + assert.equal(filters[0].rule, bareRule(checkpoint.packet.desiredRule)) + assert.deepEqual(patchBodies, [checkpoint.packet.desiredRule]) + + // Link the operation to the tick as for the existing effect fixtures. + // The worker still validates the signed plan, charge and native readback. + await pool.query("UPDATE mcp_routing_profiles SET state=state || $2::jsonb WHERE id=$1", [persistent.id, JSON.stringify({ planId: plan.planId, candidateId: "b" })]) + await tickRoutingProfiles() + const published = await readProfile() + assert.equal(published.enabled, true, String(published.state.pausedReason)) + assert.equal(published.state.planId, undefined) + const advanced = await assertRoutingControllerBaseline(published.state.baseline, persistent.id, persistent.version, config, grant, context) + assert.equal(advanced.packet.candidateId, "b") + assert.equal(advanced.packet.previousProof, previous.proof) + assert.notEqual(advanced.packet.targetProof, checkpoint.packet.desiredTargetProof) + assert.deepEqual(advanced.packet.transition, { planId: plan.planId, operationId: forward.id, checkpointProof: checkpoint.checkpointProof, + sourceDesiredTargetProof: checkpoint.packet.desiredTargetProof }) + + const inverse = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: forward.id }) as { planId: string } + assert.notEqual(inverse.planId, plan.planId) + await assert.rejects(applyPlan(principal, inverse.planId, randomUUID()), { code: "APPROVAL_REQUIRED" }) + assert.equal(writes, 1) + await approvePlan(inverse.planId, writer, true) + const recovered = await execute(inverse.planId) + assert.equal(recovered.state, "applied") + assert.equal(filters[0].rule, bareRule(initial)) + assert.equal(filters[1].rule, foreign) + assert.deepEqual(patchBodies, [checkpoint.packet.desiredRule, initial]) + assert.equal(writes, 2) + const inverseIntents = (await pool.query("SELECT state,recovery_operation_id FROM mcp_scoped_recovery_intents WHERE source_operation_id=$1", [forward.id])).rows + assert.deepEqual(inverseIntents, [{ state: "verified", recovery_operation_id: recovered.id }]) + assert.equal(inversePostChecks, 1) + await tickRoutingProfiles() + const paused = await readProfile() + assert.equal(paused.enabled, false) + assert.equal(paused.state.pausedReason, "STATE_CONFLICT") + assert.deepEqual(paused.state.baseline, advanced) + assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore) + return { accepted: true, realSql: true, loopbackHttp: true, signedBaselinePublished: true, + sameSourceApprovedInverseVerified: true, controllerPausedAfterInverse: true, nativeWrites: writes } + } if (options.originalEffectsOnly) { const cases: Array> = [] let publicManualRevision2: Record | undefined diff --git a/backend/src/modules/mcp/routing-community-scoped-model.test.ts b/backend/src/modules/mcp/routing-community-scoped-model.test.ts index 172bbfb2..05d4194e 100644 --- a/backend/src/modules/mcp/routing-community-scoped-model.test.ts +++ b/backend/src/modules/mcp/routing-community-scoped-model.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { test } from "node:test" import { compileManagedFilterRules } from "../../services/filter-rules-write-model.js" -import { compileScopedCommunitySwitch, scopedConfigurationMatches } from "./routing-community-scoped-model.js" +import { compileScopedCommunitySwitch, scopedConfigurationMatches, scopedTargetRuleMatches } from "./routing-community-scoped-model.js" const rules = [{ community: "65000:10", action: "route" as const, gateway: "192.0.2.1", outIface: "", description: "" }, { community: "65000:20", action: "route" as const, gateway: "192.0.2.1", outIface: "", description: "" }, @@ -22,6 +22,27 @@ test("full roundtrip rejects hidden script, unknown branches, comments, invalid assert.throws(() => compileScopedCommunitySwitch(body, "65000:30", "192.0.2.9", "", "ipv4")) assert.throws(() => compileScopedCommunitySwitch(body, "65000:10", "192.0.2.9", 'bad";accept;', "ipv4")) }) +test("native optional quotes preserve exact managed body and existing proof format", () => { + const quoted = compileManagedFilterRules([{ ...rules[0], outIface: "marker-gre-a" }]) + const bare = quoted.replace('"marker-gre-a"', "marker-gre-a") + const change = (body: string) => compileScopedCommunitySwitch(body, "65000:10", "192.0.2.9", "marker-gre-b", "ipv4") + assert.deepEqual(change(bare), change(quoted)) + assert.match(change(bare).compiledBody, / set gw-interface "marker-gre-b";/) + const proof = (body: string) => JSON.stringify({ id: "*1", body }) + const target = (rule: string) => ({ rawRow: { rule }, desiredTargetProof: proof }) + assert.equal(scopedTargetRuleMatches(target(bare), quoted, proof(quoted)), true) + assert.equal(scopedTargetRuleMatches(target(quoted), bare, proof(bare)), true) + assert.equal(scopedTargetRuleMatches(target(bare), quoted, proof(bare)), false) + assert.equal(scopedTargetRuleMatches(target(bare), quoted, JSON.stringify({ id: "*2", body: quoted })), false) + for (const altered of [bare.replace("marker-gre-a", "marker-gre-x"), bare.replace("192.0.2.1", "192.0.2.2"), + bare.replace(" set gw-interface", " set gw-interface"), bare.replace("gw-interface", "out-interface"), bare + "\naccept;"]) { + assert.equal(scopedTargetRuleMatches(target(altered), quoted, proof(quoted)), false) + } + for (const name of ["marker:gre/a", "100", "marker gre a"]) { + const unusual = compileManagedFilterRules([{ ...rules[0], outIface: name }]) + assert.equal(scopedTargetRuleMatches(target(unusual.replace('"' + name + '"', name)), unusual, proof(unusual)), false) + } +}) test("scoped configuration comparison needs dependency, native metadata and candidate identity conjunction", () => { const input = { dependencyProof: "dependencies", originalTargetProof: "original", desiredTargetProof: "desired", candidateId: "b" } const state = { dependencyProof: "dependencies", targetRuleProof: "desired", currentCandidateId: "b" } diff --git a/backend/src/modules/mcp/routing-community-scoped-model.ts b/backend/src/modules/mcp/routing-community-scoped-model.ts index 71ef57df..34d244db 100644 --- a/backend/src/modules/mcp/routing-community-scoped-model.ts +++ b/backend/src/modules/mcp/routing-community-scoped-model.ts @@ -1,5 +1,6 @@ import { isIP } from "node:net" import { compileManagedFilterRules, filterRuleBranches, filterRuleToken } from "../../services/filter-rules-write-model.js" +import { managedRoutingFilterBodiesMatch } from "../../services/routing-filter-body.js" type Rule = { community: string; action: "route" | "blackhole"; gateway: string; outIface: string; description: string } /** Deliberately accepts only the complete compiler output, never partial script parsing. */ @@ -11,7 +12,7 @@ export function compileScopedCommunitySwitch(body: string, community: string, ga const blackhole = /^\s*set blackhole yes;\s*accept;\s*$/.test(branch.body) for (const match of branch.condition.matchAll(/bgp-communities includes (\d+:\d+)/g)) rules.push({ community: match[1], action: blackhole ? "blackhole" : "route", gateway: blackhole ? "" : filterRuleToken(branch.body, "gw"), outIface: blackhole ? "" : filterRuleToken(branch.body, "interface"), description: "" }) } - if (!rules.length || compileManagedFilterRules(rules) !== body || new Set(rules.map(rule => rule.community)).size !== rules.length) throw new Error("Full compiler roundtrip required") + if (!rules.length || !managedRoutingFilterBodiesMatch(body, compileManagedFilterRules(rules)) || new Set(rules.map(rule => rule.community)).size !== rules.length) throw new Error("Full compiler roundtrip required") const selected = rules.filter(rule => rule.community === community) if (selected.length !== 1 || selected[0].action !== "route") throw new Error("Unique routed community required") const before = { ...selected[0] } @@ -20,6 +21,11 @@ export function compileScopedCommunitySwitch(body: string, community: string, ga return { compiledBody, before, proposed: desired.find(rule => rule.community === community)! } } +export function scopedTargetRuleMatches(target: { rawRow: Record; desiredTargetProof: (body: string) => string }, expectedBody: string, expectedProof: unknown) { + return typeof expectedProof === "string" && managedRoutingFilterBodiesMatch(target.rawRow.rule, expectedBody) && + target.desiredTargetProof(expectedBody) === expectedProof +} + export function scopedConfigurationMatches(input: { dependencyProof: unknown; originalTargetProof: unknown; desiredTargetProof: unknown; candidateId: unknown }, observed: { dependencyProof: unknown; targetRuleProof: unknown; currentCandidateId: unknown }) { const dependencies = input.dependencyProof === observed.dependencyProof return { configurationMatchesOriginal: dependencies && input.originalTargetProof === observed.targetRuleProof, diff --git a/backend/src/modules/mcp/routing-community-scoped-recovery.ts b/backend/src/modules/mcp/routing-community-scoped-recovery.ts index 1480d6e9..c944701a 100644 --- a/backend/src/modules/mcp/routing-community-scoped-recovery.ts +++ b/backend/src/modules/mcp/routing-community-scoped-recovery.ts @@ -1,3 +1,4 @@ +import { withPartitionWrite } from "../../services/partition-write-transaction.js" import { randomUUID } from "node:crypto" import { z } from "zod" import type { PoolClient } from "pg" @@ -156,7 +157,7 @@ async function observe(value: Session, phase: "before" | "after", requireSla = t const expectedBody = phase === "before" ? packet.desiredRule : packet.originalRule const expectedProof = phase === "before" ? packet.desiredTargetProof : packet.originalTargetProof const expectedCandidate = phase === "before" ? packet.desiredCandidateId : packet.originalCandidateId - if (context.before.dependencyProof !== packet.dependencyProof || context.before.targetRuleProof !== expectedProof || context.before.currentCandidateId !== expectedCandidate || context.ownedTarget.rawRow.rule !== expectedBody) throw fail("Конфигурация или зависимости обратного действия изменены") + if (context.before.dependencyProof !== packet.dependencyProof || context.before.currentCandidateId !== expectedCandidate || !context.ownedTarget.matchesRuleProof(expectedBody, expectedProof)) throw fail("Конфигурация или зависимости обратного действия изменены") await context.fence(); await value.authorize() return context } @@ -420,8 +421,10 @@ export function registerScopedRecoveryService() { reconcile: async (input, authorize) => { const value = await session(input, false, authorize, true), context = await readRouteCommunityContext(value.source.profile, value.options) const packet = value.source.configuration.packet, dependencies = context.before.dependencyProof === packet.dependencyProof - const configurationMatchesDesired = dependencies && context.before.targetRuleProof === packet.originalTargetProof && context.before.currentCandidateId === packet.originalCandidateId - const configurationMatchesOriginal = dependencies && context.before.targetRuleProof === packet.desiredTargetProof && context.before.currentCandidateId === packet.desiredCandidateId + const target = context.ownedTarget + const sameTarget = target?.nativeId === packet.nativeId + const configurationMatchesDesired = dependencies && sameTarget && context.before.currentCandidateId === packet.originalCandidateId && target.matchesRuleProof(packet.originalRule, packet.originalTargetProof) + const configurationMatchesOriginal = dependencies && sameTarget && context.before.currentCandidateId === packet.desiredCandidateId && target.matchesRuleProof(packet.desiredRule, packet.desiredTargetProof) const record = await verifiedRecord(input, value) const configurationCheckpointAvailable = await configurationRecord(input, value) const expected = record ? await recoveryResult(input, record.verifiedProof) : null @@ -437,5 +440,5 @@ export function registerScopedRecoveryService() { /** Restart maintenance is read-only with respect to the network. */ export async function recoverScopedRecoveryIntents() { - await pool.query("UPDATE mcp_scoped_recovery_intents r SET state='unknown',finished_at=now() WHERE r.state='intent' AND (r.deadline<=now() OR r.recovery_operation_id IS NOT NULL AND EXISTS (SELECT 1 FROM mcp_operations o WHERE o.id=r.recovery_operation_id AND o.state NOT IN ('pending','running')))") + await withPartitionWrite(({ client }) => client.query("UPDATE mcp_scoped_recovery_intents r SET state='unknown',finished_at=now() WHERE r.state='intent' AND (r.deadline<=now() OR r.recovery_operation_id IS NOT NULL AND EXISTS (SELECT 1 FROM mcp_operations o WHERE o.id=r.recovery_operation_id AND o.state NOT IN ('pending','running')))")) } diff --git a/backend/src/modules/mcp/routing-community-scoped-service.ts b/backend/src/modules/mcp/routing-community-scoped-service.ts index ac98cc83..8a347354 100644 --- a/backend/src/modules/mcp/routing-community-scoped-service.ts +++ b/backend/src/modules/mcp/routing-community-scoped-service.ts @@ -9,12 +9,13 @@ import { pool } from "../../db/index.js" import { hasPermission } from "../../lib/permissions.js" import { assertVerifiedConfigurationAccess, executeGuardedConfigurationWrite } from "../../services/config-mutation-policy.js" import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js" +import { managedRoutingFilterBodiesMatch } from "../../services/routing-filter-body.js" import { effectiveMcpConnection, audit } from "./store.js" import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js" import { saveScopedConfigurationCheckpoint, loadScopedConfigurationCheckpoint, saveScopedNetworkCheckpoint, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js" import { assertScopedCommunityRequest as sealedInput, readScopedPathCheckpoint } from "./routing-community-scoped-request.js" import { prepareManagementTransportBinding } from "./management-transport.js" -import { readRouteCommunityContext } from "./routing-community-context.js" +import { readRouteCommunityContext, type OwnedRouteCommunityTarget } from "./routing-community-context.js" import { candidateMatchesGateway } from "./routing-community-context-model.js" import { rankRoutingCandidates } from "./routing-model.js" import { readRoutingObservations } from "./routing-observations.js" @@ -84,6 +85,13 @@ async function receipt(input: Record, action: string) { await audit(pool, String(input.ownerId), action, principalSchema.parse(input.principal).connectionId, { receiptId: input.receiptId, requestSeal, revision: input.proofRevision }) } const matches = (input: Record, context: { before: Parameters[1] }) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before) +async function matchesDesired(input: Record, context: { before: Parameters[1]; ownedTarget: Pick | undefined }) { + if (matches(input, context).configurationMatchesDesired) return true + const target = context.ownedTarget + return !!target && target.nativeId === input.nativeId && context.before.dependencyProof === input.dependencyProof && + context.before.currentCandidateId === input.candidateId && + target.matchesRuleProof(await decryptSecret(String(input.desiredRuleCiphertext)), input.desiredTargetProof) +} async function original(input: Record, verified: boolean) { await sealedInput(input) const value = await observe(input, undefined, verified) @@ -125,7 +133,7 @@ export async function prepareScopedCommunitySwitch(input: Recordnow()", [profile.serverId])).rows.length > 0 if (hasChannel) { @@ -199,7 +207,7 @@ export function registerScopedCommunityService() { await patchScopedCommunity(owned.endpoint, owned.nativeId, fresh.compiledBody) }) const after = await observe(input, undefined, true, undefined, "configuration") - if (!matches(input, after.context).configurationMatchesDesired) throw fail("Read-back не подтверждён") + if (!await matchesDesired(input, after.context)) throw fail("Read-back не подтверждён") await after.context.fence() // This commit precedes the first post-change network probe. A failed // probe cannot erase the exact, acknowledged configuration read-back. @@ -223,7 +231,7 @@ export function registerScopedCommunityService() { if (effectSession && !effectSession.readBudget().closed) { try { const published = await readRoutingNetworkScopedCommunityContext(effectSession.sharedContext) - if (!matches(input, published).configurationMatchesDesired) throw fail("Состояние изменилось после effect-check") + if (!await matchesDesired(input, published)) throw fail("Состояние изменилось после effect-check") await published.fence() sharedPublication = true } catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) } @@ -233,7 +241,7 @@ export function registerScopedCommunityService() { // never duplicated or counted as a completed effect-owner read. // Closed/exhausted effect owners cannot waive this fresh guard. const final = await observe(input, undefined, true, undefined, "configuration") - if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe") + if (!await matchesDesired(input, final.context)) throw fail("Состояние изменилось после probe") await final.context.fence() } if (effectSession && originalEffects) { @@ -252,11 +260,12 @@ export function registerScopedCommunityService() { verify: async (input, result) => { const expected = await verifiedResult(input) if (!expected.configurationCheckpointAvailable || !expected.controlProbeVerified || fingerprint(result) !== fingerprint(expected)) return false - return matches(input, (await observe(input, undefined, false, undefined, "configuration")).context).configurationMatchesDesired + return matchesDesired(input, (await observe(input, undefined, false, undefined, "configuration")).context) }, reconcile: async (input, authorize) => { await sealedInput(input); await authorize() - const observed = await observe(input, undefined, false, authorize, "configuration"), config = matches(input, observed.context) + const observed = await observe(input, undefined, false, authorize, "configuration") + const config = { ...matches(input, observed.context), configurationMatchesDesired: await matchesDesired(input, observed.context) } await observed.context.fence(); await authorize() const expected = await verifiedResult(input) const checkpoints = (await pool.query("SELECT s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id WHERE o.connection_id=$1 AND s.state='verified' AND s.intended_state=$2::jsonb AND s.result=$3::jsonb LIMIT 2", [principalSchema.parse(input.principal).connectionId, JSON.stringify(redact({ kind: "service", service: "routing.community.scoped", input })), JSON.stringify(expected)])).rows diff --git a/backend/src/modules/mcp/routing-controller-active-writer.test.ts b/backend/src/modules/mcp/routing-controller-active-writer.test.ts index 44c15757..f88a4df7 100644 --- a/backend/src/modules/mcp/routing-controller-active-writer.test.ts +++ b/backend/src/modules/mcp/routing-controller-active-writer.test.ts @@ -12,7 +12,7 @@ async function fixture() { const input: Record = { profile: { autoRecovery: false }, persistentProfileAuthority: authority, nativeId: "*1", originalCandidateId: "a", originalTargetProof: "target-a", dependencyProof: "dep", contextSources: enrolled.sourceProofs } const prepared: Record = { ...input, desiredCandidateId: "b" } const packet: Record = { nativeId: "*1", originalCandidateId: "a", desiredCandidateId: "b", originalTargetProof: "target-a", desiredTargetProof: "target-b", dependencyProof: "dep", contextSources: enrolled.sourceProofs } - const source = { row: { id: "source-op" }, input, configuration: { checkpointProof: "configuration-proof", packet }, sourceProof: "source-proof" } + const source = { row: { id: "source-op", source_plan_id: "source-plan" }, input, configuration: { checkpointProof: "configuration-proof", packet }, sourceProof: "source-proof" } const row = { id: "profile", version: 1, connection_id: "connection", grant_expires_at: new Date(Date.now() + 600000), config: {}, state: { baseline: { proof: "inert-authenticated-baseline" } } } const metadata = { operationName: "routing.community.switch", operationVersion: 7, serviceName: "routing.community.scoped", phase: "forward", stepIndex: 0, ownerId: "owner", serviceInput: input, principal: {} } let nativeWrites = 0 @@ -55,10 +55,35 @@ for (const [name, key, bad] of drifts) test(`scoped ${name} drift is refused bef test("main fixed loaded original identity is admitted", async () => { const value = await fixture(); value.metadata.serviceName = "routing.community"; await value.run() }) for (const [name, key, bad] of drifts) test(`main decrypted ${name} drift is refused before native`, async () => { const value = await fixture(); value.metadata.serviceName = "routing.community"; value.prepared[key] = bad; await assert.rejects(value.run); assert.equal(value.writes(), 0) }) test("inverse with enrolled A awaiting its own B transition is admitted", async () => { const value = await fixture(); value.recovery(); await value.run() }) -test("inverse from this exact source advanced B baseline is admitted", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { operationId: "source-op", checkpointProof: "configuration-proof" } }); await value.run() }) -test("inverse with unrelated B source transition is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { operationId: "different-source", checkpointProof: "configuration-proof" } }); await assert.rejects(value.run); assert.equal(value.writes(), 0) }) +const sourceTransition = { operationId: "source-op", planId: "source-plan", checkpointProof: "configuration-proof" } +test("inverse from this exact source advanced B baseline is admitted", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: sourceTransition }); await value.run() }) +test("inverse from this source can bind a separately authenticated B readback seal", async () => { + const value = await fixture() + value.recovery() + Object.assign(value.enrolled, { candidateId: "b", targetProof: "observed-b", transition: { ...sourceTransition, sourceDesiredTargetProof: "target-b" } }) + await value.run() + assert.equal(value.writes(), 1) +}) +for (const [key, wrong] of Object.entries({ operationId: "other-source", planId: "other-plan", checkpointProof: "other-checkpoint", sourceDesiredTargetProof: "other-desired" })) { + test(`inverse from observed B refuses mismatched ${key}`, async () => { + const value = await fixture() + value.recovery() + Object.assign(value.enrolled, { candidateId: "b", targetProof: "observed-b", transition: { ...sourceTransition, sourceDesiredTargetProof: "target-b", [key]: wrong } }) + await assert.rejects(value.run) + assert.equal(value.writes(), 0) + }) +} +test("new forward source must match the enrolled observed seal", async () => { + const value = await fixture() + Object.assign(value.enrolled, { candidateId: "b", targetProof: "observed-b", transition: { ...sourceTransition, sourceDesiredTargetProof: "target-b" } }) + Object.assign(value.input, { originalCandidateId: "b", originalTargetProof: "target-b" }) + await assert.rejects(value.run) + assert.equal(value.writes(), 0) + value.input.originalTargetProof = "observed-b" + await value.run() +}) test("inverse with B but no authenticated transition is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b" }); await assert.rejects(value.run) }) -test("inverse with another configuration checkpoint is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { operationId: "source-op", checkpointProof: "other-checkpoint" } }); await assert.rejects(value.run) }) +test("inverse with another configuration checkpoint is refused", async () => { const value = await fixture(); value.recovery(); Object.assign(value.enrolled, { candidateId: "b", targetProof: "target-b", transition: { ...sourceTransition, checkpointProof: "other-checkpoint" } }); await assert.rejects(value.run) }) test("inverse with external C baseline is refused", async () => { const value = await fixture(); value.recovery(); value.enrolled.candidateId = "c"; await assert.rejects(value.run) }) test("inverse with another authenticated source identity is refused", async () => { const value = await fixture(); value.recovery(); value.metadata.serviceInput.sourceProof = "other"; await assert.rejects(value.run) }) test("inverse with source dependency drift is refused", async () => { const value = await fixture(); value.recovery(); value.source.configuration.packet.dependencyProof = "other"; await assert.rejects(value.run) }) diff --git a/backend/src/modules/mcp/routing-controller-active-writer.ts b/backend/src/modules/mcp/routing-controller-active-writer.ts index d95e5776..2bd11e60 100644 --- a/backend/src/modules/mcp/routing-controller-active-writer.ts +++ b/backend/src/modules/mcp/routing-controller-active-writer.ts @@ -49,8 +49,8 @@ export async function assertActiveRoutingProfileWriter(serverId:number) { const principal=await routingControllerBaselinePrincipal(row.state?.baseline),connection=await effectiveMcpConnection(principal) const baseline=await authenticateRoutingControllerBaseline(row.state?.baseline,row.id,row.version,routeAiProfileSchema.parse(row.config),row.grant_expires_at.toISOString(),{principal,connection}) const enrolled=baseline.value.packet - const matches=(identity:{nativeId:unknown;candidateId:unknown;targetProof:unknown;dependencyProof:unknown;sourceProofs:unknown}) => - identity.nativeId===enrolled.nativeId && identity.candidateId===enrolled.candidateId && identity.targetProof===enrolled.targetProof && + const matches=(identity:{nativeId:unknown;candidateId:unknown;targetProof:unknown;dependencyProof:unknown;sourceProofs:unknown}, targetProof=enrolled.targetProof) => + identity.nativeId===enrolled.nativeId && identity.candidateId===enrolled.candidateId && identity.targetProof===targetProof && identity.dependencyProof===enrolled.dependencyProof && fingerprint(identity.sourceProofs)===fingerprint(enrolled.sourceProofs) if(metadata.serviceName==="routing.community"){ const prepared=await loadRoutingMainTransitionPreparation(input) @@ -68,8 +68,10 @@ export async function assertActiveRoutingProfileWriter(serverId:number) { const packet=recoverySource.configuration.packet const common={nativeId:packet.nativeId,dependencyProof:packet.dependencyProof,sourceProofs:packet.contextSources} const original=matches({...common,candidateId:packet.originalCandidateId,targetProof:packet.originalTargetProof}) - const ownDesired=matches({...common,candidateId:packet.desiredCandidateId,targetProof:packet.desiredTargetProof}) && - enrolled.transition?.operationId===recoverySource.row.id && enrolled.transition?.checkpointProof===recoverySource.configuration.checkpointProof + const transition=enrolled.transition + const ownDesired=!!transition && transition.operationId===recoverySource.row.id && transition.planId===recoverySource.row.source_plan_id && + transition.checkpointProof===recoverySource.configuration.checkpointProof && + matches({...common,candidateId:packet.desiredCandidateId,targetProof:packet.desiredTargetProof},transition.sourceDesiredTargetProof??enrolled.targetProof) if(!original&&!ownDesired)throw deny() } } diff --git a/backend/src/modules/mcp/routing-controller-baseline-acceptance.ts b/backend/src/modules/mcp/routing-controller-baseline-acceptance.ts index 8959b544..bb2716d4 100644 --- a/backend/src/modules/mcp/routing-controller-baseline-acceptance.ts +++ b/backend/src/modules/mcp/routing-controller-baseline-acceptance.ts @@ -162,7 +162,7 @@ export async function acceptMainBaselineRuntime(actor: AuthUser) { await pool.query("INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,ping_ms,loss_pct,dl_mbps,ul_mbps,ping_at,bandwidth_at,context_verified,source) VALUES($1,$2,$2,$3,'peer','ipv4','main',1,0,100,100,now(),now(),true,'fixture')", [observationId, sid, iface]) await attestRoutingFixture(observationId) } - const writeAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Main Route context write", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.community.switch", "routing.profiles.save", "routing.profiles.read", "routing.path.verify"], allowGlobal: false }, limits: { callsPerMinute: 120 } }), writerActor) + const writeAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Main Route context write", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.community.switch", "routing.profiles.save", "routing.profiles.read", "routing.controller.read", "routing.controller.update", "routing.path.verify"], allowGlobal: false }, limits: { callsPerMinute: 120 } }), writerActor) writeConnectionId = writeAccess.connection.id const writer = await authenticateMcpToken(writeAccess.token) const automatic = routeAiProfileSchema.parse({ ...mainProfile, autoRecovery: true, candidates: mainProfile.candidates.map(candidate => ({ ...candidate, controlProbe: base.candidates[0].controlProbe })) }) @@ -226,6 +226,22 @@ export async function acceptMainBaselineRuntime(actor: AuthUser) { console.log("TARGETED_PUBLIC_OVERLAP_TOCTOU_NATIVE0_PASS",JSON.stringify({newProfilesApprovedBeforeEitherEnabled:2,firstActivationApplied:true,secondTerminalCode:overlapTerminal.error_code,secondCasRows:0,enabledProfiles:1,newPrepareRefused:true,nativeWrites:0})) const publicProfile=await invokeMcpTool(writer,"mm_route_profiles_list",{}) assert.ok(!JSON.stringify(publicProfile).includes(controller.state.baseline.proof),"Public profile DTO must omit baseline proof") + const beforeControllerRead = requests + const controllerView = await invokeMcpTool(writer, "mm_route_controller_get", { profileId: controller.id }) as { id: string; enabled: boolean; state: Record } + assert.equal(controllerView.id, controller.id) + assert.equal(controllerView.enabled, true) + assert.equal(controllerView.state.baseline, undefined) + const hiddenConfig = { ...mainProfile, candidates: mainProfile.candidates.map(candidate => ({ ...candidate, exitNodeId: sid + 1000 })) } + await pool.query("UPDATE mcp_routing_profiles SET config=$2::jsonb WHERE id=$1", [controller.id, JSON.stringify(hiddenConfig)]) + try { + const notFound = (error: unknown) => error instanceof Error && "code" in error && error.code === "NOT_FOUND" + await assert.rejects(invokeMcpTool(writer, "mm_route_controller_get", { profileId: controller.id }), notFound) + await assert.rejects(invokeMcpTool(writer, "mm_route_profile_save_plan", { profileId: controller.id, profile: mainProfile, enabled: false, expiresAt: activation.grantExpiresAt }), notFound) + } finally { + await pool.query("UPDATE mcp_routing_profiles SET config=$2::jsonb WHERE id=$1", [controller.id, JSON.stringify(mainProfile)]) + } + assert.equal(requests, beforeControllerRead) + console.log("✓ Route AI controller: exact public state, hidden dependencies reject read and replacement before RouterOS") // Explicit out-of-application drift after enrollment, before a trusted // private host prepares another persistent plan. MAC/authority stay valid. policyComment=legacyComment+"-external-drift" @@ -302,7 +318,8 @@ export async function acceptMainBaselineRuntime(actor: AuthUser) { await assert.rejects(client.patch("/routing/filter/rule/*f",{rule:body})) assert.equal(writes,beforeDenied,"Active controller refuses foreign native write before I/O") // Disable through the same public human save/CAS service, not direct SQL. - const disable=await invokeMcpTool(writer,"mm_route_profile_save_plan",{profileId:controller.id,profile:mainProfile,enabled:false,expiresAt:activation.grantExpiresAt}) as {planId:string} + await assert.rejects(invokeMcpTool(writer, "mm_route_controller_update_plan", { profileId: randomUUID(), profile: mainProfile, enabled: false, expiresAt: activation.grantExpiresAt }), (error: unknown) => error instanceof Error && "code" in error && error.code === "NOT_FOUND") + const disable=await invokeMcpTool(writer,"mm_route_controller_update_plan",{profileId:controller.id,profile:mainProfile,enabled:false,expiresAt:activation.grantExpiresAt}) as {planId:string} await approvePlan(disable.planId,writerActor,true) assert.equal((await waitOperation((await applyPlan(writer,disable.planId,"runtime-main-baseline-disable")).id)).state,"applied") // Native writer owns the REAL server lock first. Approved activation cannot diff --git a/backend/src/modules/mcp/routing-controller-baseline.test.ts b/backend/src/modules/mcp/routing-controller-baseline.test.ts new file mode 100644 index 00000000..1d0641fb --- /dev/null +++ b/backend/src/modules/mcp/routing-controller-baseline.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { basename, dirname, join, resolve } from "node:path" +import { test, type TestContext } from "node:test" +import ts from "typescript" +import { z } from "zod" +import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js" +import * as security from "./security.js" +import type * as Baseline from "./routing-controller-baseline.js" + +// Run the actual baseline implementation and durable HMAC on a temporary key. +// Only the SQL/RouterOS/authority adapters supply synthetic snapshots. +async function fixture(t: TestContext) { + const root = resolve(tmpdir()), prefix = "mcp-controller-baseline-test-" + const state = await mkdtemp(join(root, prefix)), previous = process.env.MCP_STATE_DIR + process.env.MCP_STATE_DIR = state + t.after(async () => { + if (previous === undefined) delete process.env.MCP_STATE_DIR + else process.env.MCP_STATE_DIR = previous + assert.equal(dirname(resolve(state)), root) + assert(basename(state).startsWith(prefix)) + await rm(state, { recursive: true }) + }) + const { fingerprint, privateSecretFingerprint } = security + const now = Date.now(), profileId = randomUUID(), planId = randomUUID(), operationId = randomUUID(), connectionId = randomUUID() + const grant = new Date(now + 120000).toISOString() + const principal = { connectionId, credentialId: randomUUID(), credentialExpiresAt: grant } + const profile = { serverId: 1, table: "tenant", probeVrf: "tenant", candidates: ["a", "b"].map(id => ({ id, jumpHostId: 1, exitNodeId: 1 })) } + const permissions = ["mm:network:read", "mm:network:write", "mm:filters:write"] + const connection = { id: connectionId, owner_id: "owner", generation: await security.accessGeneration(), + owner_valid_until: new Date(grant), owner_claims_issued_at: new Date(now), owner_permissions: permissions, + policy: { status: "active", mode: "operator", expiresAt: grant, permissions, + scope: { serverIds: [1], operationIds: ["routing.analyze", "routing.community.switch", "routing.path.verify"] } } } + const context = { principal, connection } as Parameters[4] + const originalRule = 'if (bgp-communities includes 65000:10) {\n set gw 192.0.2.1;\n set gw-interface "marker-gre-a";\n accept;\n}' + const desiredRule = originalRule.replace("192.0.2.1", "192.0.2.9").replace("marker-gre-a", "marker-gre-b") + const bareRule = desiredRule.replace('"marker-gre-b"', "marker-gre-b") + const signTarget = await security.privateFingerprinter("baseline-test-native-target") + const stableTarget = { ".id": "*1", chain: "input", comment: "managed" } + let observed = { ...stableTarget, rule: originalRule }, candidate = "a", failAfterRead = false, driftSourceAtFence = false + const targetProof = (rule: string) => signTarget(fingerprint({ ...observed, rule })) + const desiredTargetProof = targetProof(desiredRule), dependencyProof = "d".repeat(64), checkpointProof = "c".repeat(64) + const sourceProofs = [{ serverId: 1, proof: "a".repeat(64) }] + const receiptId = randomUUID(), requestSeal = "request-proof", networkSeal = "network-proof" + const input = { proofRevision: 3, profile, principal, ownerId: connection.owner_id, nativeId: "*1", originalCandidateId: "a", + originalTargetProof: targetProof(originalRule), desiredTargetProof, dependencyProof, contextSources: sourceProofs, candidateId: "b", + persistentProfileAuthority: { packet: { profileId, profileVersion: 1 } }, receiptId } + const step = { kind: "service", service: "routing.community.scoped", input } + const configuration = { checkpointProof, observedAt: new Date(now-100).toISOString(), packet: { revision: 2, operationId, stepIndex: 0, + nativeId: "*1", originalCandidateId: "a", originalTargetProof: input.originalTargetProof, desiredCandidateId: "b", desiredTargetProof, + dependencyProof, contextSources: sourceProofs, desiredRule } } + const nativeVerificationSeal = await privateSecretFingerprint(fingerprint({ request: requestSeal, dependency: dependencyProof, desired: desiredTargetProof, + candidate: "b", configuration: checkpointProof, network: networkSeal }), "route-ai-scoped-verified-v3") + const row = { id: operationId, plan_id: planId, state: "applied", step_state: "verified", operation_id: "routing.community.switch", + operation_version: 7, approved_by: "owner", created_at: new Date(now-200), intended_state: security.redact(step), + evidence: { sealed: await security.encryptSecret(JSON.stringify({ steps: [step], target: { scope: "servers", serverIds: [1] } })) }, + result: { nativeVerificationSeal, configurationCheckpointProof: checkpointProof, networkCheckpointProof: networkSeal, receiptId, + proofRevision: 3, nativeWrites: 1, configurationVerified: true, postProbeStatus: "passed", controlProbeVerified: true } } + const providers: Record = { + "./routing-main-transition.js": {}, + "../../db/index.js": { pool: { query: async (_sql: string, ids: string[]) => { + assert.deepEqual(ids, [operationId, planId, connectionId]) + return { rows: [failAfterRead ? { ...row, step_state: "unknown" } : row] } + } } }, + "./registry.js": { findMcpOperation: () => ({ version: 7 }) }, + "./routing-community-scoped-request.js": { assertScopedCommunityRequest: async () => requestSeal }, + "./routing-profile-authority.js": { assertPersistentScopedProfileAuthority: async () => {}, + routingProfileAuthorityBindingSchema: { parse: (value: unknown) => value } }, + "./routing-community-scoped-checkpoint.js": { loadScopedConfigurationPacket: async () => configuration, + loadScopedNetworkCheckpoint: async () => ({ status: "passed", networkSeal }) }, + "./routing-original-effects.js": {}, + zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema: { parse: (value: unknown) => value } }, + "../../lib/permissions.js": { hasPermission: (set: string[], permission: string) => set.includes(permission) }, + "./store.js": { effectiveMcpConnection: async () => connection }, + "./security.js": security, + "./routing-community-context.js": { readRouteCommunityContext: async (_profile: unknown, options: { authorize(): Promise }) => { + await options.authorize() + const target = { nativeId: observed[".id"], rawRow: observed, desiredTargetProof: targetProof } + return { before: { nativeRuleId: target.nativeId, scopedExecutionAvailable: true, currentCandidateId: candidate, + targetRuleProof: targetProof(observed.rule), dependencyProof, sourceProofs }, + ownedTarget: { ...target, matchesRuleProof: (body: string, proof: unknown) => scopedTargetRuleMatches(target, body, proof) }, + fence: async () => { await options.authorize(); if (driftSourceAtFence) failAfterRead = true } } + } }, + } + const source = await readFile(new URL("./routing-controller-baseline.ts", import.meta.url), "utf8") + const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + const baseline = {} as typeof Baseline + new Function("exports", "require", compiled)(baseline, (name: string) => { assert(providers[name], name); return providers[name] }) + const original = await baseline.prepareRoutingControllerBaseline(profileId, 1, profile, grant, context) + const links = { planId, operationId, checkpointProof } + return { original, links, desiredTargetProof, targetProof, originalRule, desiredRule, bareRule, baseline, + advance: () => baseline.advanceRoutingControllerBaseline(original, profileId, 1, profile, grant, context, planId, operationId), + assertCurrent: (value: unknown) => baseline.assertRoutingControllerBaseline(value, profileId, 1, profile, grant, context), + driftAfterNativeRead: () => { driftSourceAtFence = true }, + observe: (rule: string, metadata: Partial = {}, selected = "b") => { candidate = selected; observed = { ...stableTarget, ...metadata, rule } } } +} + +test("controller signs an exact legacy-shaped next baseline when native target proof matches", async t => { + const f = await fixture(t) + await f.assertCurrent(f.original) + f.observe(f.desiredRule) + const next = await f.advance() + assert.equal(next.packet.targetProof, f.desiredTargetProof) + assert.deepEqual(next.packet.transition, f.links) + assert.equal(next.packet.previousProof, f.original.proof) + assert.deepEqual(await f.assertCurrent(next), next) +}) + +test("controller authenticates the source but enrolls the exact bare RouterOS readback", async t => { + const f = await fixture(t) + f.observe(f.bareRule) + const next = await f.advance() + assert.equal(next.packet.targetProof, f.targetProof(f.bareRule)) + assert.notEqual(next.packet.targetProof, f.desiredTargetProof) + assert.deepEqual(next.packet.transition, { ...f.links, sourceDesiredTargetProof: f.desiredTargetProof }) + assert.deepEqual(await f.assertCurrent(next), next) + const tampered = structuredClone(next) + tampered.packet.transition!.sourceDesiredTargetProof = "e".repeat(64) + await assert.rejects(f.baseline.routingControllerBaselinePrincipal(tampered), { code: "STATE_CONFLICT" }) + f.observe(f.desiredRule) + await assert.rejects(f.assertCurrent(next), { code: "STATE_CONFLICT" }) + f.observe(f.originalRule, {}, "a") + await assert.rejects(f.assertCurrent(next), { code: "STATE_CONFLICT" }) + await f.assertCurrent(f.original) +}) + +test("controller refuses changed selected rule and drift in other native row fields", async t => { + const f = await fixture(t) + f.observe(f.bareRule.replace("marker-gre-b", "other")) + await assert.rejects(f.advance(), { code: "STATE_CONFLICT" }) + f.observe(f.bareRule, { comment: "changed" }) + await assert.rejects(f.advance(), { code: "STATE_CONFLICT" }) +}) + +test("controller refuses baseline publication if the source operation changes after readback", async t => { + const f = await fixture(t) + f.observe(f.bareRule) + f.driftAfterNativeRead() + await assert.rejects(f.advance(), { code: "STATE_CONFLICT" }) +}) diff --git a/backend/src/modules/mcp/routing-controller-baseline.ts b/backend/src/modules/mcp/routing-controller-baseline.ts index a746c3ef..c8b02f4f 100644 --- a/backend/src/modules/mcp/routing-controller-baseline.ts +++ b/backend/src/modules/mcp/routing-controller-baseline.ts @@ -23,7 +23,8 @@ const packetSchema = z.object({ nativeId: z.string().regex(/^\*[a-z0-9]+$/i), candidateId: z.string().min(1), targetProof: proof, dependencyProof: proof, sourceProofs: z.array(z.object({ serverId: z.number().int().positive(), proof }).strict()).min(1).max(1000), - observedAt: z.string().datetime(), previousProof: proof.optional(), transition: z.object({ planId: z.string().uuid(), operationId: z.string().uuid(), checkpointProof: proof }).strict().optional(), + observedAt: z.string().datetime(), previousProof: proof.optional(), + transition: z.object({ planId: z.string().uuid(), operationId: z.string().uuid(), checkpointProof: proof, sourceDesiredTargetProof: proof.optional() }).strict().optional(), }).strict() const baselineSchema = z.object({ packet: packetSchema, proof }).strict() export type RoutingControllerBaseline = z.infer @@ -186,14 +187,18 @@ export async function advanceRoutingControllerBaseline(rawBaseline: unknown, pro result.networkCheckpointProof !== network.networkSeal || result.receiptId !== input.receiptId || result.proofRevision !== input.proofRevision || result.nativeWrites !== 1 || result.configurationVerified !== true || result.postProbeStatus !== "passed" || result.controlProbeVerified !== true) throw fail() const native = await readRouteCommunityContext(profile, { authorize, signal: context.signal, expectedSources: original.sourceProofs, selectedCandidateId: packet.desiredCandidateId }) - if (!supportedExecution(profile,native.before) || native.ownedTarget?.nativeId !== original.nativeId || native.ownedTarget?.rawRow.rule !== packet.desiredRule || - native.before.currentCandidateId !== packet.desiredCandidateId || native.before.targetRuleProof !== packet.desiredTargetProof || + const targetProof = native.before.targetRuleProof + if (!supportedExecution(profile,native.before) || native.ownedTarget?.nativeId !== original.nativeId || native.before.nativeRuleId !== original.nativeId || + !targetProof || !native.ownedTarget.matchesRuleProof(packet.desiredRule, packet.desiredTargetProof) || native.before.currentCandidateId !== packet.desiredCandidateId || native.before.dependencyProof !== original.dependencyProof || fingerprint(native.before.sourceProofs) !== fingerprint(original.sourceProofs)) throw fail() await native.fence(); await authorize() await assertPersistentScopedProfileAuthority(input, context.signal) if (fingerprint(await readSource()) !== sourceIdentity) throw fail() - const next = packetSchema.parse({ ...original, candidateId: packet.desiredCandidateId, targetProof: packet.desiredTargetProof, - observedAt: new Date().toISOString(), previousProof: value.proof, transition: { planId, operationId, checkpointProof: configuration.checkpointProof } }) + // Enroll the exact observed native seal for the next tick. The source seal + // still identifies this operation and its inverse when RouterOS omits quotes. + const next = packetSchema.parse({ ...original, candidateId: packet.desiredCandidateId, targetProof, + observedAt: new Date().toISOString(), previousProof: value.proof, transition: { planId, operationId, checkpointProof: configuration.checkpointProof, + ...(targetProof !== packet.desiredTargetProof ? { sourceDesiredTargetProof: packet.desiredTargetProof } : {}) } }) return { packet: next, proof: await sign(next) } } diff --git a/backend/src/modules/mcp/routing-controls.ts b/backend/src/modules/mcp/routing-controls.ts index ba4828ac..52772df5 100644 --- a/backend/src/modules/mcp/routing-controls.ts +++ b/backend/src/modules/mcp/routing-controls.ts @@ -4,7 +4,8 @@ import { z } from "zod" import { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.js" import { pool } from "../../db/index.js" import { hasManagedCommentPrefix } from "../../managed-markers.js" -import { readRosSnapshot, routerClient, type ConfigurationRow } from "./resources.js" +import { configurationRows, readRosSnapshot, readRoutingPolicySnapshot, resourceById, routerClient, rosSnapshotSourceProof, type ConfigurationRow, type RosSnapshot } from "./resources.js" +import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js" import { McpError, fingerprint } from "./security.js" import { effectiveMcpConnection } from "./store.js" import { hasPermission } from "../../lib/permissions.js" @@ -19,7 +20,7 @@ import { readBgpSessionsForServer, readBgpCatalogForServer, readRawBgpSessionsFo import { pollGreCatalog } from "../../services/gre-catalog-poll.js" import { fetchServerFilters, readFilterRulesCatalog } from "../../routes/filters.js" import { readRecursiveCatalogForServer, readRecursiveGatewayCatalogForServer, readRecursiveRoutesForServer, readRecursiveGatewaysForServer } from "../../routes/recursive-routes.js" -import { analyzeEcmpPreflight, preflightPaths, preflightObservation, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js" +import { analyzeEcmpPreflight, confirmedEcmpInterfaces, confirmedEcmpTable, duplicateEcmpAdjacency, ecmpAdjacency, preflightPaths, preflightObservation, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js" import { readLatestInternetPathSnapshot } from "../../services/internet-path-read.js" import { projectInternetPathObservation } from "../../services/internet-path-observation.js" import { readOspfOptimizationPreview } from "../../routes/ospf.js" @@ -27,6 +28,35 @@ import { readOspfOptimizationPreview } from "../../routes/ospf.js" const serverId = z.number().int().positive(), objectId = z.string().min(1).max(100) const text = z.string().min(1).max(100), family = z.enum(["ipv4", "ipv6"]) const bool = (value: unknown) => value === true || value === "true" || value === "yes" + +export function checkRpfPolicy(serverId: number, snapshots: RosSnapshot[], mode: string) { + const find = (resource: string) => snapshots.find(snapshot => snapshot.serverId === serverId && snapshot.resource === resource) + const tables = find("routing_table"), vrfs = find("vrf"), rules = find("routing_policy:routing_rule"), mangle = find("routing_policy:ip_mangle") + if (!tables || !vrfs || !rules || !mangle || !find("ipv4_route")) { + throw new McpError("PLAN_EXPIRED", "План не содержит актуальные зависимости RPF; подготовьте его заново", 409) + } + const policyRouting = rules.rows.some(row => !bool(row.disabled)) + || mangle.rows.some(row => !bool(row.disabled) && (row.action === "mark-routing" || typeof row.action !== "string" || !row.action.trim())) + const separateTables = [...tables.rows, ...vrfs.rows].some(row => row.name !== "main") + if (mode === "strict" && (policyRouting || separateTables)) throw new McpError("STATE_CONFLICT", "Strict RPF требует main без отдельных таблиц, VRF и действующих правил policy routing", 409) + return policyRouting || separateTables +} + +export async function readRpfDependencies(serverId: number, mode: string, sourceProof: string | undefined) { + if (!sourceProof) throw new McpError("STATE_CONFLICT", "Источник IPv4-настроек RPF не подтверждён", 409) + const [tables, vrfs, routes] = await Promise.all( + ["routing_table", "vrf", "ipv4_route"].map(resource => readRosSnapshot(serverId, resource, undefined, sourceProof)), + ) + const snapshots = [tables, vrfs, routes] + const warnings = ["RPF является IPv4-настройкой. Изменение может затронуть асимметричный трафик; снижение защиты требует компенсирующих правил."] + if (mode !== "no") { + const [rules, mangle] = await Promise.all(["routing_rule", "ip_mangle"].map(resource => readRoutingPolicySnapshot(serverId, resource, sourceProof))) + snapshots.push(rules, mangle) + if (checkRpfPolicy(serverId, snapshots, mode)) warnings.push("Обнаружена policy routing или отдельные таблицы. Проверьте обратный путь для трафика в каждой затронутой таблице.") + } + return { snapshots, warnings } +} + export function registerRoutingControls(register: typeof add) { register("routing.ospf.application.preview","mm_ospf_application_preview","Модельный OSPF preview приложения с отдельными наблюдаемыми метриками","network","mm:network:read","read","servers",z.object({serverId:z.number().int().positive().safe(),pingWeight:z.number().int().min(0).max(100).default(60),peerServerIds:z.array(z.number().int().positive().safe()).max(10).refine(ids=>new Set(ids).size===ids.length).optional(),offset:z.number().int().min(0).max(100000).default(0),limit:z.number().int().min(1).max(200).default(100)}).strict(),{ read:async(input,context)=>{ @@ -95,11 +125,16 @@ export function registerRoutingControls(register: typeof add) { register("routing.ecmp.preflight.read", "mm_ecmp_preflight_get", "Предварительная проверка ECMP: NAT/FastTrack/RPF/маркировки без записи", "network", "mm:network:read", "read", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length) }).strict(), { read: async input => { const { client } = await routerClient(Number(input.serverId)), selectedFamily = input.family as "ipv4" | "ipv6" - const entries = await Promise.all(Object.entries(preflightPaths(selectedFamily)).map(async ([key, path]) => { - try { return [key, preflightObservation(await client.get(path), key === "settings" || key === "connectionTracking")] as const } - catch { return [key, { rows: [], error: "Устройство не подтвердило чтение ресурса" }] as const } - })) - return { serverId: input.serverId, observedAt: new Date().toISOString(), ...analyzeEcmpPreflight(selectedFamily, input.routeIds as string[], Object.fromEntries(entries) as Record) } + const read = async (path: string, singleton = false): Promise => { + try { return preflightObservation(await client.get(path), singleton) } + catch { return { rows: [], error: "Устройство не подтвердило чтение ресурса" } } + } + const entries = await Promise.all(Object.entries(preflightPaths(selectedFamily)).map(async ([key, path]) => [key, await read(path, key === "settings" || key === "connectionTracking")] as const)) + const observed = Object.fromEntries(entries) as Record + const ids = input.routeIds as string[] + const needsInventory = observed.routes.rows.some(row => ids.includes(String(row[".id"])) && typeof row["immediate-gw"] === "string" && row["immediate-gw"].length > 0 && !ecmpAdjacency(row["immediate-gw"])) + const inventory = needsInventory ? await read("/interface") : undefined + return { serverId: input.serverId, observedAt: new Date().toISOString(), ...analyzeEcmpPreflight(selectedFamily, ids, observed, inventory) } }, }) const recursivePaging = z.object({ serverId, offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(200).default(100) }).strict() @@ -202,44 +237,91 @@ export function registerRoutingControls(register: typeof add) { }, }) register("routing.ecmp.update", "mm_ecmp_plan", "Равная стоимость маршрутов ECMP и hash policy", "network", "mm:network:write", "write", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length), distance: z.number().int().min(1).max(255).default(1), hashPolicy: z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), { - version: 2, + version: 7, prepare: async input => { - const sid = Number(input.serverId), resource = input.family === "ipv4" ? "ipv4_route" : "ipv6_route" - const { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, resource), ids = input.routeIds as string[] - const raw = await client.get(input.family === "ipv4" ? "/ip/route" : "/ipv6/route") + const sid = Number(input.serverId), ipv4 = input.family === "ipv4", resource = ipv4 ? "ipv4_route" : "ipv6_route" + const base = ipv4 ? "ip" : "ipv6", settingsResource = `${base}_settings` + const { client, server } = await routerClient(sid) + const sourceProof = await rosSnapshotSourceProof(server) + const assertSource = async () => { + if (await rosSnapshotSourceProof((await routerClient(sid)).server) !== sourceProof) { + throw new McpError("STATE_CONFLICT", "Источник маршрутов ECMP изменился при подготовке", 409) + } + } + const read = (resourceId: string) => readRosSnapshot(sid, resourceId, undefined, sourceProof) + const readNative = async (path: string) => { + const value = await readRouterOsSnapshotGet({ client, path, assertCurrent: assertSource }) + await assertSource() + return preflightObservation(value) + } + const snapshot = await read(resource), ids = input.routeIds as string[] + const observed = await readNative(`/${base}/route`) + if (observed.error) throw new McpError("MEASUREMENT_UNKNOWN", observed.error, 409) + if (fingerprint(configurationRows(resourceById(resource), observed.rows)) !== snapshot.fingerprint) { + throw new McpError("STATE_CONFLICT", "Каталог маршрутов ECMP изменился при подготовке", 409) + } + const raw = observed.rows const rows = ids.map(id => raw.find(row => row[".id"] === id)) if (rows.some(row => !row || bool(row.dynamic) || bool(row.disabled) || bool(row.invalid) || !hasManagedCommentPrefix(String(row.comment ?? "")))) throw new McpError("PROTECTED_OBJECT", "ECMP требует разрешаемые статические маршруты приложения") const routes = rows as ConfigurationRow[], first = routes[0] if (routes.some(row => row["dst-address"] !== first["dst-address"] || String(row["routing-table"] ?? "main") !== String(first["routing-table"] ?? "main"))) throw new McpError("STATE_CONFLICT", "ECMP требует одинаковые prefix и routing table", 409) - if (new Set(routes.map(row => String(row.gateway))).size !== ids.length || routes.some(row => !row.gateway || !row["immediate-gw"])) throw new McpError("MEASUREMENT_UNKNOWN", "Нужны разные подтверждённые next-hop и immediate gateway", 409) + const gateways = routes.map(row => row.gateway) + if (gateways.some(gateway => typeof gateway !== "string" || !gateway.trim()) || new Set(gateways).size !== ids.length) throw new McpError("MEASUREMENT_UNKNOWN", "Нужны разные подтверждённые gateway", 409) + const immediate = routes.map(row => row["immediate-gw"]) + let hops = immediate.map(value => ecmpAdjacency(value)) + let interfaceIds: Map | undefined + if (hops.some(hop => hop === null)) { + const inventory = await readNative("/interface") + if (inventory.error) throw new McpError("MEASUREMENT_UNKNOWN", inventory.error, 409) + interfaceIds = confirmedEcmpInterfaces(inventory.rows) + const names = new Set(interfaceIds.keys()) + hops = immediate.map(value => ecmpAdjacency(value, names)) + } + if (hops.some(hop => hop === null)) throw new McpError("MEASUREMENT_UNKNOWN", "Не подтверждён одиночный immediate gateway каждого маршрута", 409) + if (duplicateEcmpAdjacency(hops)) throw new McpError("STATE_CONFLICT", "Маршруты ECMP разрешаются в один immediate gateway", 409) const steps: ChangeEvidence["steps"] = ids.map(id => ({ kind: "ros", step: { serverId: sid, resource, id, action: "update", fields: { distance: String(input.distance) } } })) const snapshots = [snapshot] - const tableSnapshot = await readRosSnapshot(sid, "routing_table"), table = tableSnapshot.rows.find(row => row.name === String(first["routing-table"] ?? "main")) - if (!table || typeof table[".id"] !== "string" || bool(table.disabled)) throw new McpError("STATE_CONFLICT", "Таблица ECMP отсутствует или отключена", 409) + const tableSnapshot = await read("routing_table") + const table = confirmedEcmpTable(tableSnapshot.rows, String(first["routing-table"] ?? "main")) + if (!table) { + throw new McpError("STATE_CONFLICT", "Для ECMP нужна однозначно определённая активная таблица FIB", 409) + } snapshots.push(tableSnapshot) - const objects: ChangeEvidence["objects"] = [...ids.map(id => ({ kind: resource, id, serverId: sid })), { kind: "routing_table", id: table[".id"], serverId: sid }] + const objects: ChangeEvidence["objects"] = [...ids.map(id => ({ kind: resource, id, serverId: sid })), { kind: "routing_table", id: table.id, serverId: sid }] + const [settings, vrf, ...impacts] = await Promise.all([ + read(settingsResource), + read("vrf"), + ...[`${base}_filter`, `${base}_nat`, `${base}_mangle`, `${base}_raw`, "routing_rule"].map(resource => readRoutingPolicySnapshot(sid, resource, sourceProof)), + ]) + snapshots.push(settings, vrf, ...impacts) if (input.hashPolicy) { - const settingsResource = input.family === "ipv4" ? "ip_settings" : "ipv6_settings", key = input.family === "ipv4" ? "ipv4-multipath-hash-policy" : "multipath-hash-policy" - const settings = await readRosSnapshot(sid, settingsResource) + const key = ipv4 ? "ipv4-multipath-hash-policy" : "multipath-hash-policy" if (!(key in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило выбранную hash policy", 409) - snapshots.push(settings); steps.push({ kind: "ros", step: { serverId: sid, resource: settingsResource, action: "update", fields: { [key]: String(input.hashPolicy) } } }) + steps.push({ kind: "ros", step: { serverId: sid, resource: settingsResource, action: "update", fields: { [key]: String(input.hashPolicy) } } }) objects.push({ kind: settingsResource, id: "singleton", serverId: sid }, { kind: settingsResource, id: "new", serverId: sid }) } - const impacts = await Promise.all(["ip_settings", "ip_filter", "ip_nat", "ip_mangle"].map(resource => readRosSnapshot(sid, resource))) - snapshots.push(...impacts) - return { target: { scope: "servers", serverIds: [sid] }, objects, snapshots, steps, requiresHuman: true, - healthChecks: routes.flatMap(row => [{ serverId: sid, resource, id: String(row[".id"]), field: "resolved" as const, identity: { gateway: String(row.gateway), "immediate-gw": String(row["immediate-gw"]) } }, { serverId: sid, resource, id: String(row[".id"]), field: "active" as const, phase: "after" as const }]), - warnings: ["Равные стоимости проверяются как конфигурация. Распределение реального трафика требует отдельного наблюдения.", "Проверьте NAT, FastTrack, маркировки и асимметрию; RPF автоматически не меняется."] } + await assertSource() + const healthChecks = routes.flatMap((row, index) => { + const hop = hops[index], name = hop?.address === null ? hop.interface : null + const interfaceId = name ? interfaceIds?.get(name) : undefined + const nextHopInterface = name && interfaceId ? { name, id: interfaceId } : undefined + return [{ serverId: sid, resource, id: String(row[".id"]), field: "resolved" as const, identity: { gateway: String(row.gateway), "immediate-gw": String(row["immediate-gw"]) }, nextHopInterface }, + { serverId: sid, resource, id: String(row[".id"]), field: "active" as const, phase: "after" as const }] + }) + return { target: { scope: "servers", serverIds: [sid] }, objects, snapshots, steps, requiresHuman: true, healthChecks, + warnings: ["Равные стоимости проверяются как конфигурация. Распределение реального трафика требует отдельного наблюдения.", ipv4 ? "Проверьте NAT, FastTrack, маркировки и асимметрию. Настройка RPF сохраняется." : "Проверьте IPv6 NAT, маркировки, правила маршрутизации и асимметрию."] } }, }) - register("routing.rpf.update", "mm_rpf_plan", "Явное изменение IPv4 reverse path filter", "network", "mm:network:write", "write", "servers", z.object({ serverId, mode: z.enum(["no", "loose", "strict"]), acknowledgeAsymmetry: z.literal(true) }).strict(), { - version: 2, + register("routing.rpf.update", "mm_rpf_plan", "Явное изменение IPv4 reverse path filter", "network", "mm:network:write", "write", "servers", z.object({ serverId, family: family.default("ipv4"), mode: z.enum(["no", "disabled", "loose", "strict"]), acknowledgeAsymmetry: z.literal(true) }).strict(), { + version: 4, prepare: async input => { - const sid = Number(input.serverId), snapshots = await Promise.all(["ip_settings", "routing_table", "vrf", "ipv4_route"].map(resource => readRosSnapshot(sid, resource))) - if (!("rp-filter" in (snapshots[0].rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "RPF не подтверждён устройством", 409) - if (input.mode === "strict" && (snapshots[1].rows.some(row => row.name !== "main") || snapshots[2].rows.some(row => row.name !== "main"))) throw new McpError("STATE_CONFLICT", "Strict RPF не допускается этим планом при нескольких таблицах или VRF", 409) - return { target: { scope: "servers", serverIds: [sid] }, objects: [{ kind: "server", id: String(sid), serverId: sid }, { kind: "ip_settings", id: "singleton", serverId: sid }, { kind: "ip_settings", id: "new", serverId: sid }], snapshots, steps: [{ kind: "ros", step: { serverId: sid, resource: "ip_settings", action: "update", fields: { "rp-filter": String(input.mode) } } }], requiresHuman: true, - warnings: ["RPF является IPv4-настройкой. Изменение может затронуть асимметричный трафик; снижение защиты требует компенсирующих правил."] } + if (input.family === "ipv6") throw new McpError("UNSUPPORTED_CAPABILITY", "RouterOS rp-filter доступен только для IPv4", 409) + const sid = Number(input.serverId) + const mode = input.mode === "disabled" ? "no" : String(input.mode) + const settings = await readRosSnapshot(sid, "ip_settings") + if (!("rp-filter" in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "RPF не подтверждён устройством", 409) + const { snapshots, warnings } = await readRpfDependencies(sid, mode, settings.sourceProof) + return { target: { scope: "servers", serverIds: [sid] }, objects: [{ kind: "server", id: String(sid), serverId: sid }, { kind: "ip_settings", id: "singleton", serverId: sid }, { kind: "ip_settings", id: "new", serverId: sid }], snapshots: [settings, ...snapshots], steps: [{ kind: "ros", step: { serverId: sid, resource: "ip_settings", action: "update", fields: { "rp-filter": mode } } }], requiresHuman: true, warnings } }, }) 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(), { diff --git a/backend/src/modules/mcp/routing-forward-transport-charge.test.cjs b/backend/src/modules/mcp/routing-forward-transport-charge.test.cjs index 8d000b76..95d38dc1 100644 --- a/backend/src/modules/mcp/routing-forward-transport-charge.test.cjs +++ b/backend/src/modules/mcp/routing-forward-transport-charge.test.cjs @@ -16,7 +16,7 @@ const originalSecurity = fs.readFileSync(path.join(root, 'backend/src/modules/mc const securityAst = ts.createSourceFile('security.ts', originalSecurity, ts.ScriptTarget.Latest, true); const selected = securityAst.statements.filter(node => ts.isFunctionDeclaration(node) && ['fingerprint', 'redact'].includes(node.name?.text)).map(node => node.getText(securityAst)).join('\n'); const securityExports = {}; -const securityBody = ts.transpileModule('const sensitive = /password|passwd|secret|private.?key|preshared|pre.shared|authorization|auth.?key|authentication.?key|tcp.?md5.?key|api.?key|certificate.?contents|token|ciphertext/i; const MAX_MCP_PUBLIC_STRING_LENGTH=16000;\n' + selected, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; +const securityBody = ts.transpileModule('const sensitive = /password|passwd|secret|private.?key|preshared|pre.shared|authorization|auth.?key|authentication.?key|tcp.?md5.?key|api.?key|certificate.?contents|token|ciphertext/i; const MAX_MCP_PUBLIC_STRING_LENGTH=16000; const MAX_MCP_PUBLIC_ARRAY_ITEMS=500;\n' + selected, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; new Function('exports', 'tokenDigest', securityBody)(securityExports, sha); const { fingerprint, redact } = securityExports; class McpError extends Error { constructor(code, message) { super(message); this.code = code; } } diff --git a/backend/src/modules/mcp/routing-health-checks.ts b/backend/src/modules/mcp/routing-health-checks.ts new file mode 100644 index 00000000..cff21227 --- /dev/null +++ b/backend/src/modules/mcp/routing-health-checks.ts @@ -0,0 +1,60 @@ +import type { ChangeEvidence } from "./action-types.js" +import { confirmedEcmpInterfaces, preflightObservation } from "./ecmp-preflight.js" +import { resourceById, routerClient, rosSnapshotSourceProof } from "./resources.js" +import { McpError } from "./security.js" +import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js" + +export async function verifyHealthChecks(evidence: Pick, serverIds: number[], afterWrite: boolean, authorize?: () => Promise) { + const cache = new Map() + const interfaces = new Map>() + for (const check of evidence.healthChecks ?? []) { + if (check.phase === "after" && !afterWrite) continue + if (!serverIds.includes(check.serverId)) throw new McpError("SERVER_DENIED", "Проверка находится вне плана") + if (check.field === "state" && check.resource !== "ospf_neighbor") throw new McpError("INVALID_INPUT", "Неизвестный критерий здоровья", 409) + if (check.field === "bfd-up" && check.resource !== "bfd_session") throw new McpError("INVALID_INPUT", "Неизвестный критерий BFD", 409) + if (check.field === "resolved" && !["ipv4_route", "ipv6_route"].includes(check.resource)) throw new McpError("INVALID_INPUT", "Неизвестный критерий next-hop", 409) + const key = `${check.serverId}:${check.resource}` + if (!cache.has(key)) { + await authorize?.() + const { client, server } = await routerClient(check.serverId) + const path = resourceById(check.resource).path + const isRoute = check.resource === "ipv4_route" || check.resource === "ipv6_route" + let raw: unknown + if (isRoute) { + const expected = evidence.snapshots.find(snapshot => snapshot.serverId === check.serverId && snapshot.resource === check.resource)?.sourceProof + if (!expected) throw new McpError("PLAN_EXPIRED", "Не подтверждён источник проверки next-hop", 409) + const compareSource = async (current: typeof server) => { + if (await rosSnapshotSourceProof(current) !== expected) { + throw new McpError("STATE_CONFLICT", "Источник проверки next-hop изменился", 409) + } + } + const assertCurrent = async () => { + await authorize?.() + await compareSource((await routerClient(check.serverId)).server) + } + await compareSource(server) + raw = await readRouterOsSnapshotGet({ client, path, assertCurrent }) + await assertCurrent() + if (!interfaces.has(check.serverId) && evidence.healthChecks?.some(item => item.serverId === check.serverId && item.resource === check.resource && item.nextHopInterface)) { + const inventory = preflightObservation(await readRouterOsSnapshotGet({ client, path: "/interface", assertCurrent })) + await assertCurrent() + if (inventory.error) throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", "Каталог интерфейсов next-hop не подтверждён", 409) + interfaces.set(check.serverId, confirmedEcmpInterfaces(inventory.rows)) + } + } else { + raw = await client.get(path) + } + await authorize?.() + if (!Array.isArray(raw)) throw new McpError("STATE_UNAVAILABLE", "Ответ проверки здоровья не подтверждён", 503) + cache.set(key, raw) + } + const rows = cache.get(key)!.filter((value): value is Record => !!value && typeof value === "object" && (value as Record)[".id"] === check.id) + const row = rows.length === 1 ? rows[0] : undefined + const identityMatches = row && Object.entries(check.identity ?? {}).every(([key, value]) => row[key] === value) + if (check.nextHopInterface && interfaces.get(check.serverId)?.get(check.nextHopInterface.name) !== check.nextHopInterface.id) { + throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", "Интерфейс next-hop изменился после подготовки", 409) + } + const healthy = identityMatches && row && (check.field === "resolved" ? typeof row["immediate-gw"] === "string" && row["immediate-gw"].trim().length > 0 && ![true, "true", "yes"].includes(row.invalid as boolean | string) && ![true, "true", "yes"].includes(row.disabled as boolean | string) : check.field === "bfd-up" ? String(row.state).toLowerCase() === "up" : check.field === "state" ? String(row.state).toLowerCase() === "full" : [true, "true", "yes"].includes(row[check.field] as boolean | string)) + if (!healthy) throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", afterWrite ? "После записи не подтверждён next-hop, Full OSPF или Up BFD" : "Работоспособность next-hop/OSPF/BFD изменилась после подготовки", 409) + } +} 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 31d60cc1..e77c21f8 100644 --- a/backend/src/modules/mcp/routing-manual-inverse-history.test.ts +++ b/backend/src/modules/mcp/routing-manual-inverse-history.test.ts @@ -138,6 +138,7 @@ function retentionOwner(f:Awaited>,present=t calls.push(sql) if(sql==='SHOW transaction_isolation')return{rows:[{transaction_isolation:'read committed'}]} if(sql.includes('pg_advisory_xact_lock'))return{rows:[]} + if(sql.includes('FROM mcp_settings WHERE id=1 FOR SHARE'))return{rows:[{enabled:true,requestsRetentionDays:1,auditRetentionDays:180}]} if(sql.includes('clock_timestamp'))return{rows:[{at:new Date('2026-11-03T12:00:00.000Z')}]} if(sql.startsWith('DECLARE'))return{rows:[]} if(sql.startsWith('FETCH'))return{rows:[f.row]} @@ -159,10 +160,12 @@ test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown n const f=await retentionFixture(version),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.deepEqual(await retainManualInverseChargesWithinOwner(owner.db),{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 admission=owner.calls.findIndex(s=>s.includes('pg_advisory_xact_lock')) + const settings=owner.calls.findIndex(s=>s.includes('FROM mcp_settings WHERE id=1 FOR SHARE')) + assert(admission>=0 && admissions.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')"){}),/MAC/) const wrong=await retentionFixture();wrong.row.authority_profile_version=2;const o=retentionOwner(wrong) - await assert.rejects(retainManualInverseChargesWithinOwner(o.db,1),/hint/);assert.equal(o.deleted.length,0) + await assert.rejects(retainManualInverseChargesWithinOwner(o.db),/hint/);assert.equal(o.deleted.length,0) const tamper=await retentionFixture();tamper.intent.manualInverseCharge.proof='0'.repeat(64) tamper.row.intent_sealed=await encryptSecret(JSON.stringify(tamper.intent));tamper.row.intent_proof=await sign(tamper.intent,'route-ai-scoped-recovery-intent-v1') - const t=retentionOwner(tamper);await assert.rejects(retainManualInverseChargesWithinOwner(t.db,1),/MAC/);assert.equal(t.deleted.length,0) + const t=retentionOwner(tamper);await assert.rejects(retainManualInverseChargesWithinOwner(t.db),/MAC/);assert.equal(t.deleted.length,0) const legacy=await fixture(true,7) await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([legacy.row]).db,legacy.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/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/) + await assert.rejects(retainManualInverseChargesWithinOwner(mismatched.db),/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('{}') diff --git a/backend/src/modules/mcp/routing-manual-inverse-store.ts b/backend/src/modules/mcp/routing-manual-inverse-store.ts index 2f1ffe08..1337f9b1 100644 --- a/backend/src/modules/mcp/routing-manual-inverse-store.ts +++ b/backend/src/modules/mcp/routing-manual-inverse-store.ts @@ -3,7 +3,7 @@ import { z } from "zod" import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing" import type { ChangeEvidence } from "./action-types.js" import type { McpPrincipal, StoredPlan } from "./store.js" -import { effectiveMcpConnection } from "./store.js" +import { effectiveMcpConnection, getMcpSettings } 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" @@ -184,10 +184,10 @@ export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient, /** Existing retention floor, same admission owner, bounded returned batch. * The version column is only a candidate hint: all private evidence is checked * before DELETE. Old revision1 remains pinned while its intent survives. */ -export async function retainManualInverseChargesWithinOwner(db: PoolClient, retentionDays: number) { - if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 365) throw fail("Invalid manual inverse retention") +export async function retainManualInverseChargesWithinOwner(db: PoolClient) { if ((await db.query("SHOW transaction_isolation")).rows[0]?.transaction_isolation !== "read committed") throw fail("Manual inverse GC requires fresh read-committed owner") await db.query("SELECT pg_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))") + const { requestsRetentionDays } = await getMcpSettings(db) const observedAt=iso((await db.query("SELECT date_trunc('milliseconds',clock_timestamp()) AS at")).rows[0].at) const candidates=(await db.query<{recovery_operation_id:string;source_operation_id:string;profile_version:number}>(`SELECT c.recovery_operation_id,c.source_operation_id,c.profile_version FROM mcp_routing_manual_inverse_charges c JOIN mcp_scoped_recovery_intents r @@ -195,7 +195,7 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete WHERE r.mode='manual' AND r.authority_profile_id IS NULL AND r.authority_profile_version IS NOT NULL AND c.created_at < now()-make_interval(days=>$1) AND c.created_at < now()-interval '1 hour' AND NOT EXISTS(SELECT 1 FROM mcp_operations o WHERE o.id=c.recovery_operation_id AND o.state IN('pending','running')) - ORDER BY c.created_at,c.recovery_operation_id LIMIT 100 FOR UPDATE OF c,r`,[retentionDays])).rows + ORDER BY c.created_at,c.recovery_operation_id LIMIT 100 FOR UPDATE OF c,r`,[requestsRetentionDays])).rows const {manualInverseHistoryProjection,classifyManualInverseHistory}=await import("./routing-manual-inverse-history.js") let deleted=0 for(const candidate of candidates) { diff --git a/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts b/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts index bb7adea9..8e8963ae 100644 --- a/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts +++ b/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto" import { test } from "node:test" import { z } from "zod" import ts from "typescript" +import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js" /** Inert dependency-boundary tests only. SQL/HTTP acceptance must create the * real checkpoint through the existing manual service and production HMAC; @@ -36,6 +37,10 @@ async function fixture() { const checkpoint = { checkpointProof: "persisted-checkpoint", observedAt: new ClockDate(now - 500).toISOString(), packet } const sharedToken = Object.freeze({}), acquisition = Object.freeze({}), sharedBinding = { sourceOperationId: row.id, profile: structuredClone(profile), principal: structuredClone(principal), deadline: now + 10000, contextSources: structuredClone(input.contextSources), candidateId: "b" } + let substitutedTarget: string | undefined + const ownedTarget = () => ({ nativeId, rawRow: { rule: nativeBody }, + matchesRuleProof: (body: string, proof: unknown) => scopedTargetRuleMatches({ rawRow: { rule: nativeBody }, + desiredTargetProof: observed => observed === nativeBody ? nativeTarget : observed === packet.desiredRule ? substitutedTarget ?? "" : "" }, body, proof) }) const providers: Record = { "./routing-network-execution-session.js": { isVerifiedManualEffectOwner: (owner: unknown) => owner === sharedToken && manualOwner }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema: { parse: (value: unknown) => value } }, @@ -48,7 +53,7 @@ async function fixture() { sharedReads++; nativeReads++; if (revoked) throw Error("revoked") return { before: { scopedExecutionAvailable: !guard, scopedExecutionReason: guard ? "ROUTE_RECOVERY_EXECUTOR_UNAVAILABLE" : null, dependencyProof: nativeDependency, targetRuleProof: nativeTarget, currentCandidateId: nativeCandidate, sourceProofs: input.contextSources }, - ownedTarget: { nativeId, rawRow: { rule: nativeBody } }, fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } } + ownedTarget: ownedTarget(), fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } } } }, "./routing-network-native-session.js": { inspectRoutingNetworkSharedContext: (token: unknown) => { if (token !== sharedToken || sharedClosed) throw Error("Unknown shared context"); return sharedBinding }, assertRoutingNetworkSharedContextReadOwner: (owner: unknown, read: unknown) => { if (owner !== sharedToken || read !== acquisition || sharedClosed) throw Error("Unknown acquisition owner") }, @@ -67,13 +72,13 @@ async function fixture() { nativeReads++; sharedReads++; afterNative?.(); afterNative = undefined return { before: { scopedExecutionAvailable: !guard, scopedExecutionReason: null, dependencyProof: nativeDependency, targetRuleProof: nativeTarget, currentCandidateId: nativeCandidate, sourceProofs: input.contextSources }, - ownedTarget: { nativeId, rawRow: { rule: nativeBody } }, fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } } + ownedTarget: ownedTarget(), fence: async () => { if (revoked || sharedClosed) throw Error("revoked") } } }, readRouteCommunityContext: async (_profile: unknown, options: { authorize(): Promise; signal: AbortSignal; expectedSources: unknown }) => { assert.equal(options.signal.aborted, false); assert.deepEqual(options.expectedSources, input.contextSources) await options.authorize(); nativeReads++; afterNative?.(); afterNative = undefined return { before: { scopedExecutionAvailable: !guard, scopedExecutionReason: guard ? "ROUTE_RECOVERY_EXECUTOR_UNAVAILABLE" : null, dependencyProof: nativeDependency, targetRuleProof: nativeTarget, currentCandidateId: nativeCandidate, sourceProofs: input.contextSources }, - ownedTarget: { nativeId, rawRow: { rule: nativeBody } }, fence: options.authorize } + ownedTarget: ownedTarget(), fence: options.authorize } } }, } const source = await readFile(new URL("./routing-network-configuration-bridge.ts", import.meta.url), "utf8") @@ -98,6 +103,12 @@ async function fixture() { stats: () => ({ nativeReads, profileChecks, sourceReads, configChecks, sharedReads, sharedClosed }), advance: (ms: number) => { now += ms }, revoke: () => { revoked = true }, sourceChanged: () => { sourceChanged = true }, checkpointMissing: () => { checkpointMissing = true }, checkpointChanged: () => { checkpointChanged = true }, guard: () => { guard = true }, + bareNativeReadback: (proof: string) => { + packet.desiredRule = 'if (bgp-communities includes 65000:10) {\n set gw 192.0.2.9;\n set gw-interface "marker-gre-b";\n accept;\n}' + nativeBody = packet.desiredRule.replace('"marker-gre-b"', "marker-gre-b") + nativeTarget = "bare-target" + substitutedTarget = proof + }, drift: (field: string) => { if (field === "body") nativeBody = "other"; if (field === "dependency") nativeDependency = "other"; if (field === "target") nativeTarget = "other"; if (field === "candidate") nativeCandidate = "a"; if (field === "nativeId") nativeId = "*2"; if (field === "generation") generation = "changed" }, afterNative: (hook: () => void) => { afterNative = hook }, afterCheckpoint: (hook: () => void) => { afterCheckpoint = hook }, afterProfile: (hook: () => void) => { afterProfile = hook } } } @@ -111,6 +122,15 @@ test("fixed resolver captures opaque context and consume rechecks configuration assert.equal(f.stats().nativeReads, 2); assert(f.stats().profileChecks > 5) await assert.rejects(f.consume(token), /Unknown or consumed/); await assert.rejects(f.resolve(), /already issued/) }) +test("optional RouterOS quotes do not change the sealed checkpoint identity", async () => { + const f = await fixture() + f.bareNativeReadback("desired-target") + const observed = await f.consume(await f.resolve()) + assert.equal(observed.configuration.bodyIdentity, JSON.stringify(f.packet.desiredRule)) + const drift = await fixture() + drift.bareNativeReadback("different-native-metadata") + await assert.rejects(drift.resolve(), /drifted/) +}) for (const mode of ["positive", "unbranded", "wrong-checkpoint", "terminal-source", "standalone"] as const) { test(`manual revision-2 round ${mode}`, async () => { diff --git a/backend/src/modules/mcp/routing-network-configuration-bridge.ts b/backend/src/modules/mcp/routing-network-configuration-bridge.ts index 65b69a62..2e722ede 100644 --- a/backend/src/modules/mcp/routing-network-configuration-bridge.ts +++ b/backend/src/modules/mcp/routing-network-configuration-bridge.ts @@ -188,8 +188,9 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac selectedCandidateId: String(input.candidateId), authorize, signal, expectedSources: input.contextSources as Array<{ serverId: number; proof: string }> })) if (!context.before.scopedExecutionAvailable || !context.ownedTarget) throw fail(String(context.before.scopedExecutionReason ?? "Owned context unavailable")) const owned = context.ownedTarget - if (owned.nativeId !== original.packet.nativeId || owned.nativeId !== input.nativeId || owned.rawRow.rule !== original.packet.desiredRule || - context.before.dependencyProof !== original.packet.dependencyProof || context.before.targetRuleProof !== original.packet.desiredTargetProof || + if (owned.nativeId !== original.packet.nativeId || owned.nativeId !== input.nativeId || + context.before.dependencyProof !== original.packet.dependencyProof || + !owned.matchesRuleProof(original.packet.desiredRule, original.packet.desiredTargetProof) || context.before.currentCandidateId !== original.packet.desiredCandidateId || fingerprint(context.before.sourceProofs) !== fingerprint(original.packet.contextSources)) throw fail("Current owned rule or dependencies drifted from CONFIG_APPLIED") await guarded(() => context.fence()) const after = await checkpoint() diff --git a/backend/src/modules/mcp/routing-network-native-session.ts b/backend/src/modules/mcp/routing-network-native-session.ts index cf630845..cf8bc3db 100644 --- a/backend/src/modules/mcp/routing-network-native-session.ts +++ b/backend/src/modules/mcp/routing-network-native-session.ts @@ -15,7 +15,7 @@ import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-obse import { routeContextClients } from "./routing-community-context-native.js" import { resolveRoutingNetworkDestinationPins } from "./routing-network-native-destination.js" import { readMeasurementAddress } from "./routing-measurement-address.js" -import { readRouteCommunityContextWithSharedNativeSession } from "./routing-community-context.js" +import { readRouteCommunityContextWithSharedNativeSession, type OwnedRouteCommunityTarget } from "./routing-community-context.js" import type { ActionContext, ChangeEvidence } from "./action-types.js" import type { RoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js" @@ -45,7 +45,7 @@ type SharedBinding = Readonly<{ sourceOperationId: string; profile: z.infer; candidateId: string; signal: AbortSignal }> type SharedContextResult = { before: { scopedExecutionAvailable: boolean; scopedExecutionReason: string | null; dependencyProof: string; targetRuleProof: string | null; currentCandidateId: string | null; sourceProofs: Array<{ serverId: number; proof: string }> }; fence(): Promise; - ownedTarget: { nativeId: string; rawRow: Row } | undefined } + ownedTarget: Pick | undefined } type SharedOwner = { binding: SharedBinding; active(): void; fence(): Promise; close(): void; read(serverId: number, path: string, body?: Record): Promise; run(): Promise } const sharedContexts = new WeakMap() diff --git a/backend/src/modules/mcp/routing-network-native.sql.test.ts b/backend/src/modules/mcp/routing-network-native.sql.test.ts index ee1b2500..ba056ba8 100644 --- a/backend/src/modules/mcp/routing-network-native.sql.test.ts +++ b/backend/src/modules/mcp/routing-network-native.sql.test.ts @@ -47,7 +47,7 @@ const collections = (path: string): Record[] => { if (path === "/ip/vrf") return [{ ".id": "*v1", name: "probe", interfaces: "wan1", disabled: "false" }, { ".id": "*v2", name: "main", interfaces: "all", disabled: "false" }] if (path === "/routing/route") return [{ ".id": "*r1", afi: "ip", "dst-address": "203.0.113.0/24", "routing-table": "tenant", gateway: "192.0.2.1@main", "immediate-gw": "192.0.2.1%wan1", active: "true", dynamic: "true", contribution: "active", bgp: "true", "bgp.session": "*s1" }] if (path === "/routing/bgp/connection") return [{ ".id": "*c1", name: "peer1", instance: "default", "remote.address": "192.0.2.1", "local.address": "192.0.2.2", "remote.as": "65001", afi: "ip", templates: "", vrf: "main", "routing-table": "tenant", disabled: "false" }] - if (path === "/routing/bgp/session") return [{ ".id": "*s1", name: "peer1-1", instance: "default", "remote.address": "192.0.2.1", "local.address": "192.0.2.2", "remote.as": "65001", "local.afi": "ip", "remote.afi": "ip", established: "true", vrf: "main", "routing-table": "tenant" }] + if (path === "/routing/bgp/session") return [{ ".id": "*s1", name: "peer1-1", instance: "default", "remote.address": "192.0.2.1@main", "local.address": "192.0.2.2@main", "remote.as": "65001", "local.afi": "ip", "remote.afi": "ip", established: "true", "routing-table": "tenant" }] if (path === "/routing/bfd/session") return [{ ".id": "*b1", "local-address": "192.0.2.2", "remote-address": "192.0.2.1", interface: "wan1", vrf: "main", state: "up" }] if (path === "/log") return [] if (path === "/routing/table") return ["main", "tenant", "probe"].map((name, i) => ({ ".id": "*t" + i, name, fib: "true" })) diff --git a/backend/src/modules/mcp/routing-network-scoped-context.test.ts b/backend/src/modules/mcp/routing-network-scoped-context.test.ts index a1d645ce..ae33a390 100644 --- a/backend/src/modules/mcp/routing-network-scoped-context.test.ts +++ b/backend/src/modules/mcp/routing-network-scoped-context.test.ts @@ -16,6 +16,7 @@ import { canonicalVrfMembers } from "../../services/vrf-members.js" import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js" import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js" import * as contextModel from "./routing-community-context-model.js" +import { scopedTargetRuleMatches } from "./routing-community-scoped-model.js" import { prepareOriginalEffectSelection } from "./routing-original-effects.js" import { readMeasurementAddress } from "./routing-measurement-address.js" import { routeContextClients } from "./routing-community-context-native.js" @@ -95,7 +96,8 @@ async function fixture(autoRecovery = false) { await loadSource("./routing-community-context.ts", { ...common, "./routing-network-native-session.js": native, "../../services/routing-table-fib.js": { isConfirmedRoutingFibTable }, "./routing-original-effects.js": { prepareOriginalEffectSelection }, "../../services/bgp-export-model.js": { nativeBgpRows, bgpTemplateReferences }, "../../services/bgp-filter-binding-model.js": { bgpFilterName, effectiveBgpFilter, stableBgpPolicyRow }, - "./routing-community-context-model.js": contextModel, "./path-proof-purpose.js": { pathProofTag }, + "./routing-community-context-model.js": contextModel, "./routing-community-scoped-model.js": { scopedTargetRuleMatches }, + "./path-proof-purpose.js": { pathProofTag }, "./management-transport.js": { assertManagementTransport: async () => { throw Error("No alternate management fixture") } } }, context) await loadSource("./routing-network-native-session.ts", { ...common, "node:net": { isIP }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema }, "./routing-community-context.js": context, "./registry.js": { findMcpOperation: () => ({ version: 7 }) }, diff --git a/backend/src/modules/mcp/routing-observations-model.ts b/backend/src/modules/mcp/routing-observations-model.ts index cf122112..f11365fd 100644 --- a/backend/src/modules/mcp/routing-observations-model.ts +++ b/backend/src/modules/mcp/routing-observations-model.ts @@ -3,6 +3,9 @@ import type { ChangeEvidence } from "./action-types.js" import { fingerprint, McpError } from "./security.js" type Checks = NonNullable +export function latestRoutingMetric(rows: readonly RouteObservation[], metric: "pingAt" | "bandwidthAt") { + return rows.find(row => row[metric] !== null || row.sourceVerified === false) +} export function assertRoutingChecksFresh(checks: Checks, now = Date.now()) { if (checks.length > 1000 || checks.some(check => !Number.isFinite(Date.parse(check.validUntil)) || Date.parse(check.validUntil) <= now)) throw new McpError("MEASUREMENT_UNKNOWN", "Измерения маршрутизации устарели; нужен новый план", 409) } diff --git a/backend/src/modules/mcp/routing-observations.ts b/backend/src/modules/mcp/routing-observations.ts index 4cebb9e8..13427377 100644 --- a/backend/src/modules/mcp/routing-observations.ts +++ b/backend/src/modules/mcp/routing-observations.ts @@ -2,7 +2,7 @@ import type { RouteObservation } from "@mmapp/contracts/mcp-routing" import { pool } from "../../db/index.js" import { McpError } from "./security.js" import type { ChangeEvidence } from "./action-types.js" -import { assertRoutingChecksFresh, assertRoutingObservationValues } from "./routing-observations-model.js" +import { assertRoutingChecksFresh, assertRoutingObservationValues, latestRoutingMetric } from "./routing-observations-model.js" import { createRoutingSourceContext, readRoutingEndpoints, type RoutingSourceRecord } from "./routing-observation-source.js" export const routingObservationColumns = `o.id,o.src_server_id AS "srcServerId",o.dst_server_id AS "dstServerId",o.src_interface AS "srcInterface",o.dst_interface AS "dstInterface",o.family,o.routing_table AS "table",o.ping_ms AS "pingMs",o.loss_pct AS "lossPct",o.dl_mbps AS "dlMbps",o.ul_mbps AS "ulMbps",to_char(o.ping_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "pingAt",to_char(o.bandwidth_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "bandwidthAt",o.context_verified AS "contextVerified",o.source,to_char(o.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "createdAt",o.source_proof AS "sourceProof"` @@ -23,16 +23,67 @@ export async function readRoutingObservations(serverIds: number[], serverId?: nu return observations((await pool.query(`SELECT ${routingObservationColumns} FROM mcp_routing_observations o WHERE o.src_server_id=ANY($1::bigint[]) AND o.dst_server_id=ANY($1::bigint[]) AND ($2::bigint IS NULL OR o.src_server_id=$2 OR o.dst_server_id=$2) AND o.created_at>now()-interval '24 hours' ORDER BY o.created_at DESC,o.id DESC LIMIT 10000`, [serverIds, serverId ?? null])).rows, serverIds) } +interface RoutingObservationQuery { + serverId?: number + srcServerId?: number + dstServerId?: number + srcInterface?: string + dstInterface?: string + family?: "ipv4" | "ipv6" + table?: string + limit: number + cursor?: { recordedAt: string; id: string } +} + +export async function queryRoutingObservations(serverIds: number[], input: RoutingObservationQuery) { + if ([input.serverId, input.srcServerId, input.dstServerId].some(id => id !== undefined && !serverIds.includes(id))) { + throw new McpError("SERVER_DENIED", "История измерений вне разрешённой области", 403) + } + const { rows } = await pool.query(`SELECT ${routingObservationColumns} FROM mcp_routing_observations o + WHERE o.src_server_id=ANY($1::bigint[]) AND o.dst_server_id=ANY($1::bigint[]) + AND ($2::bigint IS NULL OR o.src_server_id=$2 OR o.dst_server_id=$2) + AND ($3::bigint IS NULL OR o.src_server_id=$3) AND ($4::bigint IS NULL OR o.dst_server_id=$4) + AND ($5::text IS NULL OR o.src_interface=$5) AND ($6::text IS NULL OR o.dst_interface=$6) + AND ($7::text IS NULL OR o.family=$7) AND ($8::text IS NULL OR o.routing_table=$8) + AND ($9::timestamptz IS NULL OR (o.created_at,o.id)<($9::timestamptz,$10::uuid)) + AND o.created_at>now()-interval '24 hours' + ORDER BY o.created_at DESC,o.id DESC LIMIT $11`, [serverIds, input.serverId ?? null, input.srcServerId ?? null, + input.dstServerId ?? null, input.srcInterface ?? null, input.dstInterface ?? null, input.family ?? null, input.table ?? null, + input.cursor?.recordedAt ?? null, input.cursor?.id ?? null, input.limit + 1]) + const page = await observations(rows.slice(0, input.limit), serverIds) + const last = page.at(-1) + return { observations: page, nextCursor: rows.length > input.limit && last?.recordedAt ? { recordedAt: last.recordedAt, id: last.id } : null } +} + /** Match the sealed observation to the newest record for the exact context. * No re-ranking and no replacement by an older healthy observation. */ export async function verifyRoutingObservationChecks(checks: ChangeEvidence["observationChecks"]) { if (!checks?.length) return assertRoutingChecksFresh(checks) - const { rows } = await pool.query(`SELECT latest.* FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS q(value,ordinality) + const { rows } = await pool.query(`SELECT q.ordinality AS "checkOrdinal", latest.* FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS q(value,ordinality) LEFT JOIN LATERAL(SELECT ${routingObservationColumns} FROM mcp_routing_observations o WHERE o.src_server_id=(q.value->>'srcServerId')::bigint AND o.dst_server_id=(q.value->>'dstServerId')::bigint AND o.src_interface=q.value->>'srcInterface' AND o.family=q.value->>'family' AND o.routing_table=q.value->>'table' - AND o.created_at>now()-interval '24 hours' ORDER BY o.created_at DESC,o.id DESC LIMIT 1) latest ON true ORDER BY q.ordinality`, [JSON.stringify(checks)]) - const latest = await observations(rows, [...new Set(checks.flatMap(check => [check.srcServerId, check.dstServerId]))]) + AND (q.value->>'dstInterface' IS NULL OR o.dst_interface=q.value->>'dstInterface') + AND o.created_at>now()-interval '24 hours' + AND (q.value->>'metric' IS NULL OR (o.created_at,o.id)>=(SELECT sealed.created_at,sealed.id FROM mcp_routing_observations sealed WHERE sealed.id=(q.value->>'id')::uuid)) + ORDER BY o.created_at DESC,o.id DESC LIMIT (CASE WHEN q.value->>'metric' IS NULL THEN 1 ELSE 10001 END)) latest ON true + ORDER BY q.ordinality,latest."createdAt" DESC,latest.id DESC LIMIT 10001`, [JSON.stringify(checks)]) + if (rows.length > 10000) throw new McpError("MEASUREMENT_UNKNOWN", "Слишком много новых измерений маршрутизации; нужен новый план", 409) + const verified = await observations(rows.filter(row => row.id !== null).map(({ checkOrdinal: _, ...row }) => row), [...new Set(checks.flatMap(check => [check.srcServerId, check.dstServerId]))]) + const groups = new Map() + let index = 0 + for (const row of rows) { + if (row.id === null) continue + const ordinal = Number(row.checkOrdinal) + const group = groups.get(ordinal) ?? [] + group.push(verified[index++]) + groups.set(ordinal, group) + } + const latest = checks.flatMap((check, position) => { + const candidates = groups.get(position + 1) ?? [] + const match = check.metric ? latestRoutingMetric(candidates, check.metric) : candidates[0] + return match ? [match] : [] + }) assertRoutingObservationValues(checks, latest) } diff --git a/backend/src/modules/mcp/routing-original-effects-caller.test.mjs b/backend/src/modules/mcp/routing-original-effects-caller.test.mjs index c341938a..cc19ab62 100644 --- a/backend/src/modules/mcp/routing-original-effects-caller.test.mjs +++ b/backend/src/modules/mcp/routing-original-effects-caller.test.mjs @@ -5,27 +5,58 @@ import { randomUUID } from 'node:crypto'; import test from 'node:test'; const require=createRequire(new URL('../../../package.json',import.meta.url)),ts=require('typescript'),{z}=require('zod'); const code=ts.transpileModule(await readFile(new URL('./routing-community-scoped-service.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; -async function fixture(mode='prepared',failure){ +async function loadModel(path, resolveDependency = require) { + const source = await readFile(new URL(path, import.meta.url), 'utf8'); + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; + const exports = {}; + new Function('exports', 'require', code)(exports, resolveDependency); + return exports; +} +const filterBody = await loadModel('../../services/routing-filter-body.ts'); +const pathProofPurpose = await loadModel('./path-proof-purpose.ts'); +const nativeEndpoint = await loadModel('./routing-native-endpoint.ts'); +const { originalEffectReportSchema } = await loadModel('./routing-original-effects.ts', name => { + if (name === './routing-community-context-model.js' || name === './security.js') return {}; + if (name === './routing-native-endpoint.js') return nativeEndpoint; + return require(name); +}); +class FixtureMcpError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} +async function fixture(mode='prepared',failure,{ proofRevision = 3, manualEffectPolicy } = {}){ const calls=[],principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},connection={id:principal.connectionId,owner_id:'owner',generation:'generation',owner_permissions:['mm:filters:write','mm:network:read'],policy:{mode:'copilot',permissions:['mm:filters:write','mm:network:read'],scope:{serverIds:[1]},expiresAt:new Date(Date.now()+120000).toISOString()},owner_valid_until:new Date(Date.now()+120000)}; const profile={serverId:1,family:'ipv4',table:'tenant',probeVrf:'probe',community:'65000:10',autoRecovery:false,candidates:[{id:'a',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.8',gatewayRef:{resource:'ipv4_route',id:'*r0'},controlProbe:{}},{id:'b',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.9',gatewayRef:{resource:'ipv4_route',id:'*r'},controlProbe:{}}],excludedCandidateIds:[],pinnedCandidateId:null}; - const input={serverId:1,profile,candidateId:'b',principal,ownerId:'owner',policyProof:JSON.stringify(connection.policy),connectionGeneration:'generation',generation:'access',proofRevision:3,originalCandidateId:'a',nativeId:'*1',outIface:'',originalRuleCiphertext:'original',desiredRuleCiphertext:'desired',desiredTargetProof:'desired-proof',beforeRules:[],receiptId:randomUUID()}; + const input={serverId:1,profile,candidateId:'b',principal,ownerId:'owner',policyProof:JSON.stringify(connection.policy),connectionGeneration:'generation',generation:'access',proofRevision,originalCandidateId:'a',nativeId:'*1',outIface:'',originalRuleCiphertext:'original',desiredRuleCiphertext:'desired',desiredTargetProof:'desired-proof',beforeRules:[],receiptId:randomUUID()}; + if (manualEffectPolicy) input.manualEffectPolicy = manualEffectPolicy; if(mode!=='legacy')input.originalEffectSelection=mode==='prepared'?{status:'prepared',probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe'}}:{status:'unknown',reason:'unsupported-source'}; let wrote=false,handler,network,foreign=false,reads=0,rounds=0,closed=0,ownerClosed=false,nativeCalls=100; const unknown=reason=>({scope:'prepared-route-fib-adjacency-control-effects',status:'unknown',reason,rounds:[],logCoverage:'unknown',semanticProof:false,forwardingVerified:false,autoRecoveryAllowed:false}); const report={...unknown(null),status:'observed-expected',rounds:[{controlProbe:'passed'},{controlProbe:'passed'}]}; const session={sharedContext:{},readSnapshot:async stage=>{calls.push('baseline:'+stage);return {};},readConfigurationBoundForwardSnapshot:async()=>{calls.push('round:'+(++rounds));if(failure==='round'||failure==='round-closed'){ownerClosed=failure==='round-closed';throw Error('unknown');}return {};},readBudget:()=>({deadline:Date.now()+10000,calls:nativeCalls,bytes:1000,rows:50,closed:ownerClosed}),close:()=>{closed++;ownerClosed=true;calls.push('close-session');}}; + const security = {accessGeneration:async()=>'access',decryptSecret:async x=>x,encryptSecret:async x=>x,fingerprint:JSON.stringify,McpError:FixtureMcpError,privateSecretFingerprint:async()=>'seal',redact:x=>x}; + const { readScopedPathCheckpoint } = await loadModel('./routing-community-scoped-request.ts', name => { + if (name === './path-proof-purpose.js') return pathProofPurpose; + if (name === './security.js') return security; + return require(name); + }); + const pathCheckId = randomUUID(), observedAt = new Date(); + const pathCheckpoint = { id: pathCheckId, connection_id: principal.connectionId, status: 'passed', created_at: observedAt, evidence: { observedAt: observedAt.toISOString() } }; const providers={ - 'node:crypto':{randomUUID},zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../db/index.js':{pool:{query:async sql=>({rows:String(sql).includes('FROM mcp_path_checks')?[{id:randomUUID(),connection_id:principal.connectionId,status:'passed',created_at:new Date(),evidence:{}}]:[]})}}, + 'node:crypto':{randomUUID},zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../db/index.js':{pool:{query:async (sql, values)=>({rows:String(sql).includes('FROM mcp_path_checks')&&values?.[0]===pathCheckId&&values?.[1]===principal.connectionId?[pathCheckpoint]:[]})}}, '../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)}, '../../services/config-mutation-policy.js':{assertVerifiedConfigurationAccess:async()=>{},executeGuardedConfigurationWrite:async(_sid,_method,_path,work)=>{calls.push('guarded-intent');return work();}}, '../../services/config-mutation-lock.js':{withApplicationMutationLock:work=>work(),withConfigMutationLock:(_ids,work)=>work()}, - './store.js':{effectiveMcpConnection:async()=>connection,audit:async()=>{}}, - './security.js':{accessGeneration:async()=>'access',decryptSecret:async x=>x,encryptSecret:async x=>x,fingerprint:JSON.stringify,McpError:Error,privateSecretFingerprint:async()=>'seal',redact:x=>x}, + '../../services/routing-filter-body.js': filterBody, + './store.js':{effectiveMcpConnection:async()=>connection,audit:async(_db,_owner,action)=>{calls.push(action);}}, + './security.js':security, './routing-community-scoped-checkpoint.js':{saveScopedConfigurationCheckpoint:async()=>{calls.push('CONFIG_APPLIED');},loadScopedConfigurationCheckpoint:async()=>({checkpointProof:'configuration'}),saveScopedNetworkCheckpoint:async(_input,_seal,check,originalEffects)=>{calls.push('save-network');network={status:check.status,networkSeal:'network',...(originalEffects?{originalEffects}:{})};},loadScopedNetworkCheckpoint:async()=>network}, - './routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>'request'},'./management-transport.js':{prepareManagementTransportBinding:async()=>({})}, + './routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>'request',readScopedPathCheckpoint},'./management-transport.js':{prepareManagementTransportBinding:async()=>({})}, './routing-community-context.js':{readRouteCommunityContext:async()=>{reads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:wrote?'b':'a',dependencyProof:'dep',sourceProofs:[{serverId:1,proof:'source'}]},ownedTarget:{nativeId:'*1',rawRow:{rule:wrote?'desired':'original'},endpoint:{},desiredTargetProof:()=> 'desired-proof'},fence:async()=>{},clients:new Map([[1,{get:async()=>[{'.id':'*r'}]}]]),captureOriginalEffectSelection:async()=>{calls.push('capture-prepared-selection');return input.originalEffectSelection;}};}}, './routing-community-context-model.js':{candidateMatchesGateway:()=>true},'./routing-model.js':{rankRoutingCandidates:()=>profile.candidates.map(candidate=>({candidate,eligible:true}))},'./routing-observations.js':{readRoutingObservations:async()=>[]}, - './path-verification.js':{requireRecentPath:async()=>'path-check',checkCandidatePath:async()=>{calls.push('legacy-probe');return {id:randomUUID(),candidateId:'b',status:'passed',observedAt:new Date().toISOString()};}}, + './path-verification.js':{requireRecentPath:async()=>pathCheckId,checkCandidatePath:async()=>{calls.push('legacy-probe');return {id:randomUUID(),candidateId:'b',status:'passed',observedAt:new Date().toISOString()};}}, './routing-community-scoped-model.js':{compileScopedCommunitySwitch:()=>({compiledBody:'desired',proposed:{community:'65000:10',gateway:'192.0.2.9',outIface:''},before:{community:'65000:10',gateway:'192.0.2.8',outIface:''}}),scopedConfigurationMatches:()=>({configurationMatchesOriginal:!wrote,configurationMatchesDesired:wrote&&!foreign})}, './routing-community-scoped-native.js':{patchScopedCommunity:async()=>{calls.push('PATCH');wrote=true;}},'./service-registry.js':{registerMcpService:(_name,value)=>{handler=value;}}, './routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},resolveRoutingProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x},scopedProfileAuthorityTransport:async()=>({})}, @@ -33,19 +64,57 @@ async function fixture(mode='prepared',failure){ './routing-network-execution-session.js':{createRoutingNetworkSessionForCurrentExecution:async options=>{calls.push('open-session');assert.deepEqual(Object.keys(options).sort(),['probes','servers']);if(failure==='open')throw Error('unknown');return session;}}, './routing-network-configuration-bridge.js':{resolveRoutingNetworkForwardRoundConfigurationContext:async()=>{calls.push('configuration-round-owner');return {};},closeRoutingNetworkForwardRoundConfigurationContext:()=>{calls.push('close-round-owner');}}, './routing-network-scoped-context.js':{readRoutingNetworkScopedCommunityContext:async()=>{calls.push('shared-publication');if(failure==='publication')throw Error('DO_NOT_EXPOSE_TEST_DETAIL');nativeCalls+=10;return{before:{dependencyProof:'dep',targetRuleProof:'desired-proof',currentCandidateId:'b'},fence:async()=>{}};}}, - './routing-original-effects.js':{originalEffectSelectionSchema:{safeParse:x=>x?{success:true,data:x}:{success:false}},unknownOriginalEffects:unknown,originalEffectBaselineMatches:()=>failure!=='baseline',inspectOriginalEffectRounds:async()=>{calls.push('inspect-effects');if(failure==='foreign')foreign=true;return report;}} + './routing-original-effects.js':{originalEffectReportSchema,originalEffectSelectionSchema:{safeParse:x=>x?{success:true,data:x}:{success:false}},unknownOriginalEffects:unknown,originalEffectBaselineMatches:()=>failure!=='baseline',inspectOriginalEffectRounds:async()=>{calls.push('inspect-effects');if(failure==='foreign')foreign=true;return report;}} }; const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];});exports.registerScopedCommunityService(); return {calls,input,run:()=>handler.execute(input,{operationId:randomUUID(),phase:'forward',stepIndex:0}),prepare:()=>exports.prepareScopedCommunitySwitch({profile,candidateId:'b'},{principal,connection}),stats:()=>({wrote,closed,reads}),network:()=>network}; } test('actual service consumer baselines before the single PATCH and persists separate effects after genuine CONFIG_APPLIED and manual probe',async()=>{const f=await fixture(),result=await f.run();const c=f.calls;assert(c.indexOf('baseline:before')x==='PATCH').length,1);assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.forwardingVerified,false);assert.equal(result.originalEffects.status,'observed-expected');assert.equal(result.originalEffects.budget.calls,110);assert.equal(f.stats().closed,1);assert.deepEqual(result.originalEffects,f.network().originalEffects);}); -for(const mode of ['legacy','unknown'])test(`existing ${mode} manual path does not open native effect session`,async()=>{const f=await fixture(mode),result=await f.run();assert(!f.calls.includes('open-session'));assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);}); -for(const failure of ['open','baseline','round','round-closed','publication'])test(`optional ${failure} uncertainty preserves actual manual config/control receipt with effects unknown`,async()=>{const f=await fixture('prepared',failure),result=await f.run();assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.originalEffects.status,'unknown');assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('save-network'));}); +for (const mode of ['legacy', 'unknown']) test(`existing ${mode} manual path does not open native effect session`, async () => { + const f = await fixture(mode, undefined, { proofRevision: 2 }); + const result = await f.run(); + assert(!f.calls.includes('open-session')); + assert.equal(result.configurationVerified, true); + assert.equal(result.controlProbeVerified, true); + assert.equal(f.calls.filter(call => call === 'PATCH').length, 1); +}); +for (const failure of ['open', 'baseline', 'round', 'round-closed', 'publication']) test(`optional ${failure} uncertainty preserves config/control receipt with effects unknown`, async () => { + const f = await fixture('prepared', failure, ['open', 'baseline'].includes(failure) + ? { proofRevision: 2, manualEffectPolicy: 'observational-v1' } + : { proofRevision: 3 }); + const result = await f.run(); + assert.equal(result.configurationVerified, true); + assert.equal(result.controlProbeVerified, true); + assert.equal(result.originalEffects.status, 'unknown'); + assert.equal(f.calls.filter(call => call === 'PATCH').length, 1); + assert(f.calls.includes('save-network')); +}); +for (const [description, mode, failure] of [ + ['missing selection', 'legacy'], + ['unknown selection', 'unknown'], + ['unavailable native session', 'prepared', 'open'], + ['baseline drift', 'prepared', 'baseline'], +]) { + test(`persistent revision 3 refuses ${description} before dispatch`, async () => { + const f = await fixture(mode, failure, { proofRevision: 3 }); + await assert.rejects(f.run, { code: 'MEASUREMENT_UNKNOWN' }); + for (const call of ['routing.scoped.attempt', 'guarded-intent', 'PATCH', 'CONFIG_APPLIED', 'save-network']) { + assert(!f.calls.includes(call), call); + } + }); +} test('foreign configuration drift after optional effect observations still refuses publication, never repeats PATCH',async()=>{const f=await fixture('prepared','foreign');await assert.rejects(f.run);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(!f.calls.includes('save-network'));assert.equal(f.stats().closed,1);}); test('revision2 remains on existing manual path even with a prepared-like field',async()=>{const f=await fixture();f.input.proofRevision=2;await f.run();assert(!f.calls.includes('open-session'));}); test('sealed manual policy runs read-only rounds through the real scoped consumer',async()=>{const f=await fixture();f.input.proofRevision=2;f.input.manualEffectPolicy='observational-v1';const result=await f.run();assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('round:2'));assert.equal(result.originalEffects.status,'observed-expected');}); test('actual preparation consumes the required real-context selection method and includes it in sealed service input',async()=>{const f=await fixture(),evidence=await f.prepare();assert(f.calls.includes('capture-prepared-selection'));assert.deepEqual(evidence.steps[0].input.originalEffectSelection,f.input.originalEffectSelection);assert.equal(evidence.steps[0].input.requestProof,'seal');assert(!f.calls.includes('PATCH'));}); -test('closed effect owner falls back to exactly the old final context read without bypassing publication guard',async()=>{const legacy=await fixture('legacy');await legacy.run();const f=await fixture('prepared','round-closed');await f.run();assert(!f.calls.includes('shared-publication'));assert.equal(f.stats().reads,legacy.stats().reads);}); +test('closed effect owner falls back to exactly the old final context read without bypassing publication guard', async () => { + const legacy = await fixture('legacy', undefined, { proofRevision: 2 }); + await legacy.run(); + const f = await fixture('prepared', 'round-closed', { proofRevision: 2, manualEffectPolicy: 'observational-v1' }); + await f.run(); + assert(!f.calls.includes('shared-publication')); + assert.equal(f.stats().reads, legacy.stats().reads); +}); test('optional publication error exposes only a bounded failure category', async()=>{ const f=await fixture('prepared','publication'), result=await f.run(); 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 6107197c..3e687711 100644 --- a/backend/src/modules/mcp/routing-original-effects-controller.test.mjs +++ b/backend/src/modules/mcp/routing-original-effects-controller.test.mjs @@ -10,6 +10,20 @@ const effectCode=ts.transpileModule(await readFile(new URL('./routing-original-e 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);}); +async function loadModel(path, resolveDependency) { + const source = await readFile(new URL(path, import.meta.url), 'utf8'); + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText; + const exports = {}; + new Function('exports', 'require', code)(exports, resolveDependency); + return exports; +} +const filterBody = await loadModel('../../services/routing-filter-body.ts', require); +const filterRules = await loadModel('../../services/filter-rules-write-model.ts', require); +const { scopedTargetRuleMatches } = await loadModel('./routing-community-scoped-model.ts', name => { + if (name === '../../services/routing-filter-body.js') return filterBody; + if (name === '../../services/filter-rules-write-model.js') return filterRules; + return require(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(); @@ -21,13 +35,24 @@ async function fixture(selection='absent',effectStatus='observed-expected',switc if(selection!=='absent')input.originalEffectSelection=selection?.status==='prepared'&&Object.keys(selection).length===1?structuredClone(preparedValue):selection; const packet={revision:2,operationId,stepIndex:0,nativeId:'*1',originalCandidateId:'a',originalTargetProof:originalTarget,dependencyProof:dependency,contextSources:sourceProofs,desiredCandidateId:'b',desiredTargetProof:desiredTarget,desiredRule:'desired'}; const network={status:'passed',networkSeal,...(effectStatus==='missing'?{}:{originalEffects:{status:effectStatus,switchEffectObserved:switched,selectionIdentity:hash(input.originalEffectSelection??null),transition:{changedPrefixes:switched?['203.0.113.0/24']:[]}}})}; + const readNativeContext = async () => { + nativeReads++; + const rawRow = { rule: advancing ? 'desired' : 'original' }; + const targetRuleProof = advancing ? desiredTarget : originalTarget; + const target = { nativeId: '*1', rawRow, desiredTargetProof: body => body === rawRow.rule ? targetRuleProof : hash(body) }; + return { + before: { scopedExecutionAvailable: true, currentCandidateId: advancing ? 'b' : 'a', targetRuleProof, dependencyProof: dependency, sourceProofs, nativeRuleId: '*1' }, + ownedTarget: { ...target, matchesRuleProof: (body, proof) => scopedTargetRuleMatches(target, body, proof) }, + fence: async () => {}, + }; + }; const providers={ './routing-main-transition.js':{},'../../db/index.js':{pool:{query:async()=>({rows:[row]})}},'./registry.js':{findMcpOperation:()=>({version:7})}, './routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>requestSeal},'./routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x}}, './routing-community-scoped-checkpoint.js':{loadScopedConfigurationPacket:async()=>({packet,checkpointProof,observedAt:new Date(Date.now()-500).toISOString()}),loadScopedNetworkCheckpoint:async()=>network}, zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)},'./store.js':{effectiveMcpConnection:async()=>connection}, './security.js':{accessGeneration:async()=>'access',constantEqual:(a,b)=>a===b,decryptSecret:async x=>x,fingerprint:hash,redact:x=>x,McpError:Error,privateSecretFingerprint:sign}, - './routing-community-context.js':{readRouteCommunityContext:async()=>{nativeReads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:advancing?'b':'a',targetRuleProof:advancing?desiredTarget:originalTarget,dependencyProof:dependency,sourceProofs,nativeRuleId:'*1'},ownedTarget:{nativeId:'*1',rawRow:{rule:advancing?'desired':'original'}},fence:async()=>{}};}}, + './routing-community-context.js':{readRouteCommunityContext: readNativeContext}, './routing-original-effects.js':{originalEffectSelectionSchema:effectExports.originalEffectSelectionSchema} }; const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];}); diff --git a/backend/src/modules/mcp/routing-profiles.ts b/backend/src/modules/mcp/routing-profiles.ts index e972b64e..ba8a0fd0 100644 --- a/backend/src/modules/mcp/routing-profiles.ts +++ b/backend/src/modules/mcp/routing-profiles.ts @@ -11,18 +11,29 @@ import { accessGeneration, McpError } from "./security.js" import { audit, validatePrincipal, type McpPrincipal } from "./store.js" import { decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js" import type { add } from "./actions.js" +import type { ActionContext } from "./action-types.js" import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js" import { controllerProfileCurrent, publishControllerState, saveControllerProfileCas, withControllerProfileLease } from "./routing-controller-fence.js" import { assertIdentifiedRoutingCompletion, createRoutingReservationStore } from "./routing-reservation-store.js" const targetIds = (profile: RouteAiProfile) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])] +const profilePublicColumns = "id,version,config,state,enabled,grant_expires_at,updated_at" +const profileInScope = (config: unknown, serverIds: number[]) => targetIds(routeAiProfileSchema.parse(config)).every(id => serverIds.includes(id)) +const profileSaveSchema = z.object({ profileId: z.string().uuid().optional(), profile: routeAiProfileSchema, enabled: z.boolean().default(false), expiresAt: z.string().datetime({ offset: true }) }).strict() function assertControllerCanBeEnabled(profile: RouteAiProfile) { const reason = routeAiControllerBlockReason(profile) if (reason) throw new McpError("UNSUPPORTED_CAPABILITY", reason, 409) } export function registerRoutingProfileActions(register: typeof add) { register("routing.profiles.read", "mm_route_profiles_list", "Профили и состояние контроллера Route AI", "network", "mm:network:read", "read", "connection", z.object({}).strict(), { - read: async (_input, context) => ({ profiles: (await pool.query("SELECT id,version,config,state,enabled,grant_expires_at,updated_at FROM mcp_routing_profiles WHERE connection_id=$1 ORDER BY updated_at DESC LIMIT 200", [context.connection.id])).rows.filter(row => targetIds(routeAiProfileSchema.parse(row.config)).every(id => context.connection.policy.scope.serverIds.includes(id))).map(row=>({...row,state:publicRoutingControllerState(row.state)})) }), + read: async (_input, context) => ({ profiles: (await pool.query(`SELECT ${profilePublicColumns} FROM mcp_routing_profiles WHERE connection_id=$1 ORDER BY updated_at DESC LIMIT 200`, [context.connection.id])).rows.filter(row => profileInScope(row.config, context.connection.policy.scope.serverIds)).map(row => ({ ...row, state: publicRoutingControllerState(row.state) })) }), + }) + register("routing.controller.read", "mm_route_controller_get", "Состояние выбранного контроллера Route AI", "network", "mm:network:read", "read", "connection", z.object({ profileId: z.string().uuid() }).strict(), { + read: async (input, context) => { + const { rows: [row] } = await pool.query(`SELECT ${profilePublicColumns} FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2`, [input.profileId, context.connection.id]) + if (!row || !profileInScope(row.config, context.connection.policy.scope.serverIds)) throw new McpError("NOT_FOUND", "Профиль не найден", 404) + return { ...row, state: publicRoutingControllerState(row.state) } + }, }) registerMcpService("routing.profile.save", { current: async input => (await pool.query("SELECT id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows[0] ?? null, @@ -40,24 +51,28 @@ export function registerRoutingProfileActions(register: typeof add) { }, verify: async (input, result) => !!result && (await pool.query("SELECT config,state,enabled,version,credential_id,generation,policy_version,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows.some(row => row.version === (result as { version: number }).version && row.version === (input.expectedVersion === null ? 1 : Number(input.expectedVersion) + 1) && JSON.stringify(routeAiProfileSchema.parse(row.config)) === JSON.stringify(routeAiProfileSchema.parse(input.profile)) && row.enabled === input.enabled && (!input.enabled || fingerprint(row.state.baseline)===fingerprint(input.baseline)) && row.credential_id === input.credentialId && row.generation === input.generation && row.policy_version === input.policyVersion && row.grant_expires_at.getTime() === Date.parse(String(input.expiresAt))), }) - register("routing.profiles.save", "mm_route_profile_save_plan", "Сохранить профиль и временный допуск контроллера", "network", "mm:network:write", "write", "servers", z.object({ profileId: z.string().uuid().optional(), profile: routeAiProfileSchema, enabled: z.boolean().default(false), expiresAt: z.string().datetime({ offset: true }) }).strict(), { - target: input => ({ scope: "servers", serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) }), - prepare: async (input, context) => { - const profile = routeAiProfileSchema.parse(input.profile), ids = targetIds(profile), profileId = input.profileId ?? randomUUID() - if (input.enabled) assertControllerCanBeEnabled(profile) - const expires = Date.parse(String(input.expiresAt)), credentialExpires = Date.parse(context.principal.credentialExpiresAt) - if (expires <= Date.now() || expires > Math.min(Date.now() + 86400000, credentialExpires, Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime())) throw new McpError("INVALID_EXPIRATION", "Допуск контроллера ограничен 24 часами и сроком подтверждения прав владельца", 400) - const existing = (await pool.query("SELECT id,connection_id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0] - if (existing && existing.connection_id !== context.connection.id) throw new McpError("NOT_FOUND", "Профиль не найден", 404) - if (input.enabled && !["routing.analyze", "routing.community.switch"].every(id => context.connection.policy.scope.operationIds.includes(id))) throw new McpError("OPERATION_DENIED", "Для контроллера нужны отдельные разрешения анализа и переключения") - if(input.enabled)await assertNoActiveRoutingProfileOverlap(String(profileId),profile) - const baseline=input.enabled?await prepareRoutingControllerBaseline(String(profileId),(existing?.version??0)+1,profile,String(input.expiresAt),context):undefined - const { connection_id: _connection, ...before } = existing ?? {} - return { target: { scope: "servers", serverIds: ids }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: existing ? before : null, - steps: [{ kind: "service", service: "routing.profile.save", input: { ...input, profileId, expectedVersion: existing?.version ?? null, connectionId: context.connection.id, baseline, credentialId: context.principal.credentialId, generation: await accessGeneration(), policyVersion: context.connection.policy.version } }], requiresHuman: true, - warnings: ["Контроллер использует исходный доступ, лимиты и подтверждения. Отзыв токена или изменение политики останавливает его.", "При неизвестном пути или результате записи контроллер приостанавливается."] } - }, - version: 4, + const profileTarget = (input: Record) => ({ scope: "servers" as const, serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) }) + const prepareProfileSave = (updateOnly: boolean) => async (input: Record, context: ActionContext) => { + const profile = routeAiProfileSchema.parse(input.profile), ids = targetIds(profile), profileId = input.profileId ?? randomUUID() + if (input.enabled) assertControllerCanBeEnabled(profile) + const expires = Date.parse(String(input.expiresAt)), credentialExpires = Date.parse(context.principal.credentialExpiresAt) + if (expires <= Date.now() || expires > Math.min(Date.now() + 86400000, credentialExpires, Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime())) throw new McpError("INVALID_EXPIRATION", "Допуск контроллера ограничен 24 часами и сроком подтверждения прав владельца", 400) + const existing = (await pool.query("SELECT id,connection_id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0] + if (existing ? existing.connection_id !== context.connection.id || !profileInScope(existing.config, context.connection.policy.scope.serverIds) : updateOnly) throw new McpError("NOT_FOUND", "Профиль не найден", 404) + const affectedIds = existing ? [...new Set([...ids, ...targetIds(routeAiProfileSchema.parse(existing.config))])] : ids + if (input.enabled && !["routing.analyze", "routing.community.switch"].every(id => context.connection.policy.scope.operationIds.includes(id))) throw new McpError("OPERATION_DENIED", "Для контроллера нужны отдельные разрешения анализа и переключения") + if(input.enabled)await assertNoActiveRoutingProfileOverlap(String(profileId),profile) + const baseline=input.enabled?await prepareRoutingControllerBaseline(String(profileId),(existing?.version??0)+1,profile,String(input.expiresAt),context):undefined + const { connection_id: _connection, ...before } = existing ?? {} + return { target: { scope: "servers" as const, serverIds: affectedIds }, objects: affectedIds.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: existing ? before : null, + steps: [{ kind: "service" as const, service: "routing.profile.save", input: { ...input, profileId, expectedVersion: existing?.version ?? null, connectionId: context.connection.id, baseline, credentialId: context.principal.credentialId, generation: await accessGeneration(), policyVersion: context.connection.policy.version } }], requiresHuman: true, + warnings: ["Контроллер использует исходный доступ, лимиты и подтверждения. Отзыв токена или изменение политики останавливает его.", "При неизвестном пути или результате записи контроллер приостанавливается."] } + } + register("routing.profiles.save", "mm_route_profile_save_plan", "Сохранить профиль и временный допуск контроллера", "network", "mm:network:write", "write", "servers", profileSaveSchema, { + target: profileTarget, prepare: prepareProfileSave(false), version: 5, + }) + register("routing.controller.update", "mm_route_controller_update_plan", "Обновить профиль и допуск контроллера Route AI", "network", "mm:network:write", "write", "servers", profileSaveSchema.extend({ profileId: z.string().uuid(), enabled: z.boolean() }), { + target: profileTarget, prepare: prepareProfileSave(true), }) } @@ -109,7 +124,7 @@ export async function tickRoutingProfiles() { await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection}) if (state.probedAt && Date.now() - Date.parse(state.probedAt) < 55000) return await current() - const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile }) as { ranking: ReturnType; currentCandidateId: string | null } + const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile, limit: 100 }) as { ranking: ReturnType; currentCandidateId: string | null } if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return } if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return } diff --git a/backend/src/modules/mcp/runtime.ts b/backend/src/modules/mcp/runtime.ts index 48628fa8..f3c7b9ed 100644 --- a/backend/src/modules/mcp/runtime.ts +++ b/backend/src/modules/mcp/runtime.ts @@ -1,3 +1,4 @@ +import { withPartitionWrite } from "../../services/partition-write-transaction.js" import { pollErrorLog } from "../../services/server-poll-error-provenance.js" import { vrfPreparationDiagnostic } from "./vrf-preparation-diagnostic.js" import { createVrfRuntimeDiagnosticBoundary } from "../../services/vrf-execution-diagnostic.js" @@ -31,9 +32,11 @@ import { findAction, findActionByOperation } from "./actions.js" import { refuseExpiredOperationReplay } from "./operation-replay-tombstones.js" import { accessGeneration, decryptSecret, encryptSecret, fingerprint, privateSecretFingerprint, McpError, redact } from "./security.js" import { audit, getConnection, transaction, validatePrincipal, effectiveMcpConnection, assertConnectionVisible, type McpPrincipal, type ConnectionRow, type StoredPlan, type StoredOperation } from "./store.js" -import { executeRosStep, readRosSnapshot, resourceById, routerClient, type RosSnapshot } from "./resources.js" +import { executeRosStep, readRosSnapshot, routerClient, type RosSnapshot } from "./resources.js" +import { verifyHealthChecks } from "./routing-health-checks.js" import { verifyRosStepSnapshots } from "./ros-step-verification.js" import { createRosPrefix } from "./ros-prefix.js" +import { checkRpfPolicy } from "./routing-controls.js" import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js" import { logsFollowCancellation } from "./logs-follow-cancellation.js" import { findMcpService, type McpServiceExecutionContext } from "./service-registry.js" @@ -115,10 +118,11 @@ async function reserveCall(principal: McpPrincipal, toolName: string, input: Rec if (reservation.result.decision === "denied") throw new McpError(reservation.result.reasons[0]?.code ?? "PERMISSION_DENIED", reservation.result.reasons.map(reason => reason.message).join("; ")) return { action, requestId, context: reservation.context } } -async function completeRequest(id: string, started: number, result: unknown, error?: unknown) { +async function completeRequest(id: string, started: number, result: unknown, error?: unknown, assertDeliveryAccess?: (client: PoolClient) => Promise) { await transaction(async client => { await client.query("UPDATE mcp_requests SET status=$2,result=$3::jsonb,error_code=$4,duration_ms=$5,finished_at=now() WHERE id=$1", [id, error ? "error" : "completed", JSON.stringify(redact(error ? { message: error instanceof McpError ? error.message : "Внутренняя ошибка операции" } : result)), error instanceof McpError ? error.code : error ? "INTERNAL_ERROR" : null, Math.min(2147483647, Date.now() - started)]) await client.query("DELETE FROM mcp_read_leases WHERE id=$1", [id]) + await assertDeliveryAccess?.(client) }) } export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise { @@ -198,11 +202,16 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw vrfPreparing = false } else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409) assertActive() - const current = await effectiveMcpConnection(principal) - assertActive() - if (fingerprint(current.policy) !== fingerprint(call.context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(call.context.connection.owner_permissions)) throw new McpError("STATE_CONFLICT", "Доступ изменился во время вызова; результат не выдан", 409) + const assertCurrentResultAccess = async (client?: PoolClient) => { + const current = await effectiveMcpConnection(principal, client) + assertActive() + if (fingerprint(current.policy) !== fingerprint(call.context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(call.context.connection.owner_permissions)) throw new McpError("STATE_CONFLICT", "Доступ изменился во время вызова; результат не выдан", 409) + } + await assertCurrentResultAccess() const output = redact(result) - await completeRequest(call.requestId, started, output) + const checkDelivery = action.operation.section === "logs" && action.operation.effect === "read" ? assertCurrentResultAccess : undefined + await completeRequest(call.requestId, started, output, undefined, checkDelivery) + await checkDelivery?.() assertActive() return output } catch (error) { @@ -350,6 +359,11 @@ export async function applyPlan(principal: McpPrincipal, planId: string, idempot const action = findActionByOperation(plan.operation_id) if (plan.policy_version !== connection.policy.version || plan.operation_version !== action.operation.version || plan.generation !== await accessGeneration() || plan.expires_at.getTime() <= Date.now()) throw new McpError("PLAN_EXPIRED", "План или его разрешения устарели", 409) if (!["draft", "approved"].includes(plan.state)) throw new McpError("APPROVAL_REQUIRED", "План требует подтверждения или недоступен", 409) + for (const change of evidence.steps) { + if (change.kind === "ros" && change.step.resource === "ip_settings" && change.step.fields["rp-filter"] === "strict") { + checkRpfPolicy(change.step.serverId, evidence.snapshots, "strict") + } + } let scopedPathExpiresAt: number | undefined if (plan.operation_id === "routing.community.switch" && requiresScopedRoutingOwnerApproval(evidence)) { if (plan.approved_by !== connection.owner_id) throw new McpError("APPROVAL_REQUIRED", "Переключение постоянного профиля подтверждает владелец подключения", 409) @@ -463,6 +477,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 ecmpStoppedBeforeDispatch = false let vrfDiagnosticFallback: ReturnType | undefined let reservedForwardPassed = false let reservedRecoverySummary: Record | undefined @@ -531,14 +546,25 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin if (productionVrf && (evidence.steps.length !== 1 || evidence.steps[0].kind !== "ros" || evidence.steps[0].step.resource !== "vrf")) throw new McpError("STATE_CONFLICT", "VRF execution требует exact single typed step", 409) if (await routingReservationEvidence(plan, evidence) && (!routingReservationForward || !routingReservationId)) throw new McpError("STATE_CONFLICT", "No confirmed reserved forward admission", 409) if (plan.operation_version !== findActionByOperation(plan.operation_id).operation.version) throw new McpError("PLAN_EXPIRED", "Версия операции изменилась до запуска", 409) + const checksResolvedNextHop = evidence.healthChecks?.some(check => check.field === "resolved") === true + if (plan.operation_id === "operations.recover" && !checksResolvedNextHop && evidence.steps.some(change => change.kind === "ros" && ["ipv4_route", "ipv6_route"].includes(change.step.resource))) { + const { operationId: sourceId } = JSON.parse(await decryptSecret(plan.payload.sealed)) as { operationId: string } + const { rows: [source] } = await pool.query("SELECT p.operation_id FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2", [sourceId, plan.connection_id]) + if (!source || source.operation_id === "routing.ecmp.update") throw new McpError("PLAN_EXPIRED", "Подготовьте новый обратный план с проверкой next-hop", 409) + } const applyWithLocks = () => withConfigMutationLock(serverIds, async () => { await authorize() const prefix = evidence.steps.some(step => step.kind === "ros") ? createRosPrefix(productionVrf ? evidence.snapshots.filter(snapshot => !isVrfCompanionResource(snapshot.resource)) : evidence.snapshots) : undefined + for (const change of evidence.steps) { + if (change.kind === "ros" && change.step.resource === "ip_settings" && change.step.fields["rp-filter"] === "strict") { + checkRpfPolicy(change.step.serverId, evidence.snapshots, "strict") + } + } if (prefix) await prefix.check(authorizeCatalog) else for (const snapshot of evidence.snapshots) { if ((await readRosSnapshot(snapshot.serverId, snapshot.resource)).fingerprint !== snapshot.fingerprint) throw new McpError("STATE_CONFLICT", "Конфигурация изменилась после подготовки", 409) } - await verifyHealthChecks(evidence, serverIds, false) + await verifyHealthChecks(evidence, serverIds, false, authorizeCatalog) for (const step of evidence.steps) { if (step.kind === "service" && fingerprint(await serviceCurrent(step.service, step.input)) !== fingerprint(evidence.before)) throw new McpError("STATE_CONFLICT", "Данные сервиса изменились", 409) } @@ -573,7 +599,24 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin 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() } } } : {}) }, + const beforeDispatch = step.kind === "ros" ? async () => { + await prefix!.check(authorizeCatalog) + if (checksResolvedNextHop) { + try { + await verifyHealthChecks(evidence, serverIds, false, authorizeCatalog) + } catch (error) { + ecmpStoppedBeforeDispatch = true + throw error + } + } + await authorize() + await authorizeCatalog() + if (productionVrf) { + if (!vrfBeforeDispatch) throw new McpError("STATE_CONFLICT", "VRF command guard отсутствует", 409) + await vrfBeforeDispatch() + } + } : 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()) }, beforeDispatch }, async () => { vrfDiagnosticFallback?.executorEntered() if (step.kind === "ros") { @@ -634,7 +677,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin } else if (!await verifyStep(step, result)) throw new McpError("VERIFICATION_FAILED", "Запись подтверждена, но проверка результата не прошла", 409) await pool.query("UPDATE mcp_operation_steps SET state=$3 WHERE operation_id=$1 AND step_index=$2", [operationId, index, evidence.verificationScope === "acknowledgement" ? "acknowledged" : "verified"]) } - await verifyHealthChecks(evidence, serverIds, true) + await verifyHealthChecks(evidence, serverIds, true, authorizeCatalog) await prefix?.check(authorizeCatalog) if (evidence.ospfOwnerGraph) { await authorizeGrant() @@ -675,39 +718,21 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin const diagnosticNotStarted = diagnosticOnly && confirmed === 0 && code === "READ_CANCELLED" && (error as { diagnosticNotStarted?: unknown } | null)?.diagnosticNotStarted === true && diagnosticProgress?.state === "cancelled" && diagnosticProgress.attemptedStages === 0 && diagnosticProgress.completedStages === 0 - const state = !attempted || diagnosticNotStarted ? "rejected" : code === "VERIFICATION_FAILED" ? "partial" : "unknown" + const state = ecmpStoppedBeforeDispatch ? confirmed === 0 ? "rejected" : "partial" + : !attempted || diagnosticNotStarted ? "rejected" : code === "VERIFICATION_FAILED" ? "partial" : "unknown" + const dispatchOutcome = ecmpStoppedBeforeDispatch ? { failedStepDispatched: false } : {} // Private operator STDERR only; never HTTP/result/audit payload. try { const diagnostic = pollErrorLog(error, operationId, plan.operation_id); if (diagnostic) console.error(JSON.stringify(diagnostic)) } catch { /* Logging cannot replace operation outcome. */ } const recovery = reservedRecoverySummary ?? (code === "VERIFICATION_FAILED" && confirmed === evidence.steps.length && evidence.recovery ? await performReservedRecovery(operation, plan, evidence, principal, serverIds) : undefined) await transaction(async client => { - await client.query("UPDATE mcp_operations SET state=$2,verification=$3,error_code=$4,result=$5::jsonb,finished_at=now() WHERE id=$1", [operationId, state, code === "VERIFICATION_FAILED" ? "failed" : "inconclusive", code, JSON.stringify({ confirmedWrites: diagnosticOnly ? 0 : confirmed, needsReview: attempted && !diagnosticNotStarted, recovery, ...(nativeProgress ? { nativeProgress } : {}), ...(diagnosticProgress ? { diagnosticProgress } : {}), ...(diagnosticOnly ? { completedServiceSteps: confirmed } : {}) })]) + await client.query("UPDATE mcp_operations SET state=$2,verification=$3,error_code=$4,result=$5::jsonb,finished_at=now() WHERE id=$1", [operationId, state, code === "VERIFICATION_FAILED" ? "failed" : "inconclusive", code, JSON.stringify({ confirmedWrites: diagnosticOnly ? 0 : confirmed, needsReview: state !== "rejected", ...dispatchOutcome, recovery, ...(nativeProgress ? { nativeProgress } : {}), ...(diagnosticProgress ? { diagnosticProgress } : {}), ...(diagnosticOnly ? { completedServiceSteps: confirmed } : {}) })]) await client.query("UPDATE mcp_plans SET state='completed' WHERE id=$1", [plan.id]) - await audit(client, "mcp-runner", "operation.stop", operation.connection_id, { operationId, state, code, confirmedWrites: diagnosticOnly ? 0 : confirmed, ...(nativeProgress ? { nativeProgress } : {}), ...(diagnosticProgress ? { diagnosticProgress } : {}) }) + await audit(client, "mcp-runner", "operation.stop", operation.connection_id, { operationId, state, code, confirmedWrites: diagnosticOnly ? 0 : confirmed, ...dispatchOutcome, ...(nativeProgress ? { nativeProgress } : {}), ...(diagnosticProgress ? { diagnosticProgress } : {}) }) }) } } -async function verifyHealthChecks(evidence: ChangeEvidence, serverIds: number[], afterWrite: boolean) { - const cache = new Map() - for (const check of evidence.healthChecks ?? []) { - if (check.phase === "after" && !afterWrite) continue - if (!serverIds.includes(check.serverId)) throw new McpError("SERVER_DENIED", "Проверка находится вне плана") - if (check.field === "state" && check.resource !== "ospf_neighbor") throw new McpError("INVALID_INPUT", "Неизвестный критерий здоровья", 409) - if (check.field === "bfd-up" && check.resource !== "bfd_session") throw new McpError("INVALID_INPUT", "Неизвестный критерий BFD", 409) - if (check.field === "resolved" && !["ipv4_route", "ipv6_route"].includes(check.resource)) throw new McpError("INVALID_INPUT", "Неизвестный критерий next-hop", 409) - const key = `${check.serverId}:${check.resource}` - if (!cache.has(key)) { - const { client } = await routerClient(check.serverId), raw = await client.get(resourceById(check.resource).path) - if (!Array.isArray(raw)) throw new McpError("STATE_UNAVAILABLE", "Ответ проверки здоровья не подтверждён", 503) - cache.set(key, raw) - } - const row = cache.get(key)!.find((value): value is Record => !!value && typeof value === "object" && (value as Record)[".id"] === check.id) - const identityMatches = row && Object.entries(check.identity ?? {}).every(([key, value]) => row[key] === value) - const healthy = identityMatches && row && (check.field === "resolved" ? typeof row["immediate-gw"] === "string" && row["immediate-gw"].trim().length > 0 && ![true, "true", "yes"].includes(row.invalid as boolean | string) && ![true, "true", "yes"].includes(row.disabled as boolean | string) : check.field === "bfd-up" ? String(row.state).toLowerCase() === "up" : check.field === "state" ? String(row.state).toLowerCase() === "full" : [true, "true", "yes"].includes(row[check.field] as boolean | string)) - if (!healthy) throw new McpError(afterWrite ? "VERIFICATION_FAILED" : "STATE_CONFLICT", afterWrite ? "После записи не подтверждён next-hop, Full OSPF или Up BFD" : "Работоспособность next-hop/OSPF/BFD изменилась после подготовки", 409) - } -} async function performReservedRecovery(operation: StoredOperation, plan: StoredPlan, evidence: ChangeEvidence, principal: McpPrincipal, serverIds: number[]) { const recovery = evidence.recovery! let attempted = false @@ -757,7 +782,7 @@ export async function recoverMcpOperations() { // A second backend must not interrupt a runner still holding its session lease. const { rows } = await pool.query("SELECT id,connection_id FROM mcp_operations WHERE state IN ('pending','running') AND created_at { + await withPartitionWrite(async ({ client }) => { const { rows: [lock] } = await client.query("SELECT pg_try_advisory_xact_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + row.id]) if (!lock.locked) return const changed = await client.query("UPDATE mcp_operations SET state='unknown',verification='inconclusive',error_code='PROCESS_INTERRUPTED',finished_at=now() WHERE id=$1 AND state IN ('pending','running') RETURNING plan_id", [row.id]) @@ -766,7 +791,7 @@ export async function recoverMcpOperations() { await audit(client, "mcp-recovery", "operation.interrupted", row.connection_id, { operationId: row.id, retryAllowed: false }) }) } - await pool.query("UPDATE mcp_plans SET state='expired' WHERE expires_at client.query("UPDATE mcp_plans SET state='expired' WHERE expires_at client.query("DELETE FROM mcp_read_leases WHERE expires_at client.query("UPDATE mcp_recovery_intents SET state='unknown',finished_at=now() WHERE state='intent' AND operation_id IN (SELECT id FROM mcp_operations WHERE state NOT IN ('pending','running'))")) } diff --git a/backend/src/modules/mcp/security.ts b/backend/src/modules/mcp/security.ts index b0096e49..06af6b69 100644 --- a/backend/src/modules/mcp/security.ts +++ b/backend/src/modules/mcp/security.ts @@ -116,6 +116,7 @@ export async function decryptBinary(value: Buffer): Promise { } const sensitive = /password|passwd|secret|private.?key|preshared|pre.shared|authorization|auth.?key|authentication.?key|tcp.?md5.?key|api.?key|certificate.?contents|token|ciphertext/i export const MAX_MCP_PUBLIC_STRING_LENGTH = 16000 +export const MAX_MCP_PUBLIC_ARRAY_ITEMS = 500 export function redact(value: unknown, depth = 0): unknown { if (depth > 12) return "[truncated]" if (value instanceof Date) return value.toISOString() @@ -123,7 +124,7 @@ export function redact(value: unknown, depth = 0): unknown { .replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]") .replace(/\bmmcp_[a-z]+_[A-Za-z0-9_-]+/g, "[redacted]") .replace(/((?:password|secret|private-key|preshared-key|auth-key|authentication-key|tcp-md5-key|api-key|token)\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1[redacted]") - if (Array.isArray(value)) return value.slice(0, 500).map(item => redact(item, depth + 1)) + if (Array.isArray(value)) return value.slice(0, MAX_MCP_PUBLIC_ARRAY_ITEMS).map(item => redact(item, depth + 1)) if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "__mmappMcpOperationClaimV1").slice(0, 200).map(([key, item]) => [key, sensitive.test(key) && !/Ref$|Configured$/i.test(key) ? "[redacted]" : redact(item, depth + 1)])) return value } diff --git a/backend/src/modules/mcp/store.ts b/backend/src/modules/mcp/store.ts index 7584ffa5..ac5d7e0d 100644 --- a/backend/src/modules/mcp/store.ts +++ b/backend/src/modules/mcp/store.ts @@ -57,8 +57,9 @@ export async function transaction(work: (client: PoolClient) => Promise): export async function audit(client: Pick, actorId: string, action: string, connectionId: string | null, payload: unknown): Promise { await client.query("INSERT INTO mcp_audit(actor_id,action,connection_id,payload) VALUES($1,$2,$3,$4::jsonb)", [actorId, action, connectionId, JSON.stringify(redact(payload))]) } -export async function getMcpSettings() { - const { rows: [row] } = await pool.query("SELECT enabled,requests_retention_days AS \"requestsRetentionDays\",audit_retention_days AS \"auditRetentionDays\" FROM mcp_settings WHERE id=1") +export async function getMcpSettings(retentionOwner?: PoolClient) { + const { rows: [row] } = await (retentionOwner ?? pool).query(`SELECT enabled,requests_retention_days AS "requestsRetentionDays", + audit_retention_days AS "auditRetentionDays" FROM mcp_settings WHERE id=1${retentionOwner ? " FOR SHARE" : ""}`) if (!row) throw new McpError("STATE_UNAVAILABLE", "Настройки MCP отсутствуют; выполните миграции", 503) return mcpSettingsSchema.parse(row) } diff --git a/backend/src/modules/mcp/workers.ts b/backend/src/modules/mcp/workers.ts index 10c51fca..0375038a 100644 --- a/backend/src/modules/mcp/workers.ts +++ b/backend/src/modules/mcp/workers.ts @@ -1,3 +1,8 @@ +import { isDeepStrictEqual } from "node:util" +import { withConfigMutationLock } from "../../services/config-mutation-lock.js" +import { acknowledgedCreateId } from "./native-create-identity.js" +import { readRosSnapshot } from "./resources.js" +import { proveRosDriftExpectation } from "./ros-step-verification.js" import { randomUUID } from "node:crypto" import { retainServerProbeStorage } from "../../services/server-probe-retention.js" import { maintainServerProbeOperationRetirement } from "../../services/server-probe-operation-retirement.js" @@ -6,47 +11,95 @@ import { compactCompletedServerPollStorage } from "../../services/server-poll-te import { retireCompletedServerPollOperations } from "../../services/server-poll-operation-retirement.js" import { pool } from "../../db/index.js" import { sharedDatabaseGate } from "../../services/shared-database-gate.js" -import { recoverMcpOperations, verifyStep } from "./runtime.js" +import { recoverMcpOperations } from "./runtime.js" import { retireOrdinaryMcpOperations } from "./ordinary-operation-retirement.js" import { audit, getMcpSettings } from "./store.js" -import { accessGeneration, decryptSecret, redact } from "./security.js" +import { accessGeneration, decryptSecretExisting, redact } from "./security.js" import { tickRoutingProfiles } from "./routing-profiles.js" import type { ChangeEvidence } from "./action-types.js" -import { routingReservationTransaction } from "./routing-reservation-transaction.js" +import { withPartitionWrite } from "../../services/partition-write-transaction.js" import { retainManualInverseChargesWithinOwner } from "./routing-manual-inverse-store.js" import { recoverScopedRecoveryIntents } from "./routing-community-scoped-recovery.js" +type DriftCheckpoint = { before_sealed: string; after_sealed: string; result: unknown; endpoint_epoch: string } + +const driftCheckpointSql = `SELECT k.before_sealed,k.after_sealed,s.result,device.routing_endpoint_epoch AS endpoint_epoch + FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id JOIN mcp_connections c ON c.id=o.connection_id + JOIN mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=$2 + JOIN mcp_operation_checkpoints k ON k.operation_id=s.operation_id AND k.step_index=s.step_index + JOIN servers device ON device.id=s.server_id + WHERE o.id=$1 AND c.id=$7 AND p.connection_id=c.id + AND o.state='applied' AND o.verification='passed' AND o.finished_at>now()-interval '7 days' + AND c.policy->>'status'='active' AND (c.policy->>'expiresAt')::timestamptz>now() AND c.generation=$3 + AND p.evidence->>'sealed'=$4 AND s.server_id=$5 AND s.intended_state=$6::jsonb AND device.enabled + AND s.state='verified' AND s.result IS NOT NULL AND k.after_sealed IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM mcp_operations newer JOIN mcp_operation_steps ns ON ns.operation_id=newer.id + WHERE newer.created_at>o.created_at AND ns.server_id=s.server_id)` + export async function detectMcpDrift() { - // A newer operation supersedes this expectation, even when its outcome is unknown. - const { rows } = await pool.query(`SELECT o.id,o.connection_id,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id - JOIN mcp_connections c ON c.id=o.connection_id WHERE o.state='applied' AND o.finished_at>now()-interval '7 days' + const generation = await accessGeneration() + const { rows } = await pool.query<{ id: string; connection_id: string; evidence: { sealed: string } }>(`SELECT o.id,o.connection_id,p.evidence + FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id JOIN mcp_connections c ON c.id=o.connection_id + WHERE o.state='applied' AND o.verification='passed' AND o.finished_at>now()-interval '7 days' AND c.policy->>'status'='active' AND (c.policy->>'expiresAt')::timestamptz>now() AND c.generation=$1 - ORDER BY o.finished_at DESC LIMIT 50`, [await accessGeneration()]) + ORDER BY o.finished_at DESC LIMIT 50`, [generation]) const seen = new Set() for (const row of rows) { - const evidence = JSON.parse(await decryptSecret(row.evidence.sealed)) as ChangeEvidence - for (let index = 0; index < evidence.steps.length; index++) { - const step = evidence.steps[index] - // Only native configuration has a stable expected state suitable for drift checks. - // Service receipts (e.g. backup creation) are not assertions of ongoing state. - if (step.kind !== "ros") continue - const key = `${step.step.serverId}:${step.step.resource}:${step.step.id ?? "singleton"}` - if (seen.has(key)) continue - seen.add(key) - const newer = await pool.query(`SELECT 1 FROM mcp_operations n JOIN mcp_plans p ON p.id=n.plan_id WHERE n.created_at>(SELECT created_at FROM mcp_operations WHERE id=$1) - AND EXISTS(SELECT 1 FROM mcp_operation_steps s WHERE s.operation_id=n.id AND s.server_id=$2) LIMIT 1`, [row.id, step.step.serverId]) - if (newer.rows.length) continue - const receipt = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=$2", [row.id, index])).rows[0]?.result + let steps: ChangeEvidence['steps'] + try { + const evidence = JSON.parse(await decryptSecretExisting(row.evidence.sealed)) as ChangeEvidence + if (!Array.isArray(evidence.steps)) continue + steps = evidence.steps + } catch { continue } + + const uncertainResources = new Set() + for (let index = steps.length - 1; index >= 0; index--) { + const step = steps[index] + if (step?.kind !== "ros" || !step.step) continue + const { serverId, resource, action, id } = step.step + const resourceKey = `${serverId}:${resource}` + if (uncertainResources.has(resourceKey)) continue try { - if (await verifyStep(step, receipt)) continue - const inserted = await pool.query(`INSERT INTO mcp_drift(id,operation_id,server_id,expected,observed) - VALUES($1,$2,$3,$4::jsonb,$5::jsonb) ON CONFLICT DO NOTHING RETURNING id`, [randomUUID(), row.id, step.step.serverId, - JSON.stringify(redact({ stepIndex: index, step })), JSON.stringify({ matchesExpected: false, observedAt: new Date().toISOString() })]) - if (inserted.rows.length) { - await audit(pool, "mcp-drift", "drift.detected", row.connection_id, { operationId: row.id, stepIndex: index, serverId: step.step.serverId }) - await pool.query("UPDATE mcp_routing_profiles SET enabled=false,state=state || $2::jsonb WHERE connection_id=$1 AND enabled", [row.connection_id, JSON.stringify({ pausedReason: "Обнаружен дрейф конфигурации" })]) + const result = action === "create" + ? (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=$2 AND server_id=$3", [row.id, index, serverId])).rows[0]?.result + : undefined + const nativeId = action === "create" ? acknowledgedCreateId(result) : id + if (action === "create" && !nativeId) { + // A later create without a durable native identity can shadow an older target. + uncertainResources.add(resourceKey) + continue } - } catch { /* Device unavailability is unknown, never misreported as drift. */ } + const targetKey = JSON.stringify([serverId, resource, nativeId ?? null]) + if (seen.has(targetKey)) continue + seen.add(targetKey) + + await withConfigMutationLock([serverId], async () => { + const expected = JSON.stringify(redact(step)) + const args = [row.id, index, generation, row.evidence.sealed, serverId, expected, row.connection_id] + const { rows: [checkpoint] } = await pool.query(driftCheckpointSql, args) + if (!checkpoint || await accessGeneration() !== generation) return + const before = JSON.parse(await decryptSecretExisting(checkpoint.before_sealed)) + const after = JSON.parse(await decryptSecretExisting(checkpoint.after_sealed)) + const expectation = proveRosDriftExpectation(step.step, checkpoint.result, before, after) + if (!expectation || expectation.nativeId !== nativeId) return + const current = await readRosSnapshot(serverId, resource, undefined, expectation.sourceProof) + if (expectation.matches(current) !== false) return + const observedAt = new Date().toISOString() + + await withPartitionWrite(async ({ client }) => { + const { rows: [locked] } = await client.query( + `${driftCheckpointSql} FOR SHARE OF o,p,c,s,k,device`, args) + if (!isDeepStrictEqual(locked, checkpoint) || await accessGeneration() !== generation) return + const inserted = await client.query(`INSERT INTO mcp_drift(id,operation_id,server_id,expected,observed) + VALUES($1,$2,$3,$4::jsonb,$5::jsonb) ON CONFLICT DO NOTHING RETURNING id`, [randomUUID(), row.id, serverId, + JSON.stringify(redact({ stepIndex: index, step })), JSON.stringify({ matchesExpected: false, observedAt })]) + if (inserted.rows.length) { + await audit(client, "mcp-drift", "drift.detected", row.connection_id, { operationId: row.id, stepIndex: index, serverId }) + await client.query("UPDATE mcp_routing_profiles SET enabled=false,state=state || $2::jsonb WHERE connection_id=$1 AND enabled", [row.connection_id, JSON.stringify({ pausedReason: "Обнаружен дрейф конфигурации" })]) + } + }) + }) + } catch { /* Unavailable proof or persistence leaves the next candidate eligible. */ } } } } @@ -54,31 +107,39 @@ export async function detectMcpDrift() { const mcpRetentionBatchSize = 1000 async function retainMcpData() { - const settings = await getMcpSettings() - await pool.query(`WITH expired AS ( - SELECT id FROM mcp_requests WHERE created_at$1) AND status NOT IN ('running') - ORDER BY created_at,id LIMIT $2 FOR UPDATE - ) DELETE FROM mcp_requests USING expired WHERE mcp_requests.id=expired.id`, [settings.requestsRetentionDays, mcpRetentionBatchSize]) - await pool.query(`WITH expired AS ( - SELECT id FROM mcp_audit WHERE created_at$1) - ORDER BY created_at,id LIMIT $2 FOR UPDATE - ) DELETE FROM mcp_audit USING expired WHERE mcp_audit.id=expired.id`, [settings.auditRetentionDays, mcpRetentionBatchSize]) - await pool.query(`WITH expired AS ( + await withPartitionWrite(async ({ client }) => { + const { requestsRetentionDays } = await getMcpSettings(client) + await client.query(`WITH expired AS ( + SELECT id FROM mcp_requests WHERE created_at$1) AND status NOT IN ('running') + ORDER BY created_at,id LIMIT $2 FOR UPDATE SKIP LOCKED + ) DELETE FROM mcp_requests USING expired WHERE mcp_requests.id=expired.id`, [requestsRetentionDays, mcpRetentionBatchSize]) + }) + await withPartitionWrite(async ({ client }) => { + const { auditRetentionDays } = await getMcpSettings(client) + await client.query(`WITH expired AS ( + SELECT id FROM mcp_audit WHERE created_at$1) + ORDER BY created_at,id LIMIT $2 FOR UPDATE SKIP LOCKED + ) DELETE FROM mcp_audit USING expired WHERE mcp_audit.id=expired.id`, [auditRetentionDays, mcpRetentionBatchSize]) + }) + await withPartitionWrite(({ client }) => client.query(`WITH expired AS ( SELECT id FROM mcp_artifacts WHERE expires_at retainManualInverseChargesWithinOwner(db, settings.requestsRetentionDays)) - await pool.query(`WITH candidates AS MATERIALIZED (SELECT source_operation_id FROM mcp_routing_forward_charges - WHERE created_at$1) ORDER BY created_at,source_operation_id LIMIT 1000), - expired AS (SELECT c.source_operation_id FROM candidates c WHERE NOT EXISTS(SELECT 1 FROM mcp_operations o - WHERE o.id=c.source_operation_id AND o.state IN('pending','running'))) - DELETE FROM mcp_routing_forward_charges c USING expired e WHERE c.source_operation_id=e.source_operation_id`, [settings.requestsRetentionDays]) - await pool.query("DELETE FROM mcp_routing_observations WHERE created_at client.query("DELETE FROM mcp_oauth_requests WHERE expires_at client.query("DELETE FROM mcp_budget_buckets WHERE bucket retainManualInverseChargesWithinOwner(client)) + await withPartitionWrite(async ({ client }) => { + const { requestsRetentionDays } = await getMcpSettings(client) + await client.query(`WITH candidates AS MATERIALIZED (SELECT source_operation_id FROM mcp_routing_forward_charges + WHERE created_at$1) ORDER BY created_at,source_operation_id LIMIT 1000), + expired AS (SELECT c.source_operation_id FROM candidates c WHERE NOT EXISTS(SELECT 1 FROM mcp_operations o + WHERE o.id=c.source_operation_id AND o.state IN('pending','running'))) + DELETE FROM mcp_routing_forward_charges c USING expired e WHERE c.source_operation_id=e.source_operation_id`, [requestsRetentionDays]) + }) + await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_routing_observations WHERE created_at client.query("DELETE FROM mcp_path_checks WHERE created_at client.query("DELETE FROM mcp_unauthorized_buckets WHERE bucket client.query("DELETE FROM mcp_portal_events WHERE created_at { const rows = await listServerRows() @@ -36,7 +37,9 @@ async function createServerImpl(input: ServerCreate): Promise { return toServerRead(inserted, undefined) } -export function updateServer(id: number, input: ServerUpdate): Promise { return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => updateServerImpl(id, input)) } +export function updateServer(id: number, input: ServerUpdate): Promise { + return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => withConfigMutationLock([id], () => updateServerImpl(id, input))) +} async function updateServerImpl(id: number, input: ServerUpdate): Promise { const { wanUplinks, ...rest } = input const setPayload: Record = { updatedAt: new Date().toISOString() } @@ -50,7 +53,9 @@ async function updateServerImpl(id: number, input: ServerUpdate): Promise { return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => deleteServerImpl(id)) } +export function deleteServer(id: number): Promise { + return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => withConfigMutationLock([id], () => deleteServerImpl(id))) +} async function deleteServerImpl(id: number): Promise { await deleteServerRowById(id) } diff --git a/backend/src/modules/users/service/users-service.ts b/backend/src/modules/users/service/users-service.ts index a5d2cc45..4a31929c 100644 --- a/backend/src/modules/users/service/users-service.ts +++ b/backend/src/modules/users/service/users-service.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto" +import { isDeepStrictEqual } from "node:util" import { executeGuardedApplicationWrite } from "../../../services/config-mutation-policy.js" import { and, desc, eq } from "drizzle-orm" import type { @@ -137,7 +138,15 @@ async function userMutationDependencies(id: string, added: number[] = []) { const user = await getUserById(id) return [...new Set([...added, ...(user?.servers ?? []).map(server => Number(server.serverId)), ...(user?.bindings ?? []).map(binding => binding.serverId)])] } -export function createUser(input: AppUserCreate): Promise { return executeGuardedApplicationWrite({ kind: "user", id: "new" }, (input.servers ?? []).map(server => Number(server.serverId)), () => createUserImpl(input)) } +type UserWriteOptions = { canManagePermissions?: boolean } +const permissionChangeDenied = () => new UsersServiceError("Для изменения роли и прав требуется mm:settings:admin", 403) + +export function createUser(input: AppUserCreate, { canManagePermissions = true }: UserWriteOptions = {}): Promise { + if (!canManagePermissions && ((input.role ?? "viewer") !== "viewer" || input.sections?.length || input.servers?.length)) { + return Promise.reject(permissionChangeDenied()) + } + return executeGuardedApplicationWrite({ kind: "user", id: "new" }, (input.servers ?? []).map(server => Number(server.serverId)), () => createUserImpl(input)) +} async function createUserImpl(input: AppUserCreate): Promise { const login = input.login.trim() if (await getUserRowByLogin(login)) { @@ -161,10 +170,17 @@ async function createUserImpl(input: AppUserCreate): Promise { return await toUserDto(row, []) } -export function updateUser(id: string, input: AppUserUpdate): Promise { return executeGuardedApplicationWrite({ kind: "user", id }, () => userMutationDependencies(id, (input.servers ?? []).map(server => Number(server.serverId))), () => updateUserImpl(id, input)) } -async function updateUserImpl(id: string, input: AppUserUpdate): Promise { +export function updateUser(id: string, input: AppUserUpdate, { canManagePermissions = true }: UserWriteOptions = {}): Promise { + return executeGuardedApplicationWrite({ kind: "user", id }, () => userMutationDependencies(id, (input.servers ?? []).map(server => Number(server.serverId))), () => updateUserImpl(id, input, canManagePermissions)) +} +async function updateUserImpl(id: string, input: AppUserUpdate, canManagePermissions: boolean): Promise { const existing = await getUserRowById(id) if (!existing) throw new UsersServiceError("Пользователь не найден", 404) + if (!canManagePermissions && ( + (input.role != null && input.role !== existing.role) + || (input.sections != null && !isDeepStrictEqual(input.sections, asPermArray(existing.sectionsJson, []))) + || (input.servers != null && !isDeepStrictEqual(input.servers, asPermArray(existing.serversJson, []))) + )) throw permissionChangeDenied() if (input.login != null) { const other = await getUserRowByLogin(input.login.trim()) if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409) @@ -173,12 +189,14 @@ async function updateUserImpl(id: string, input: AppUserUpdate): Promise a.name.localeCompare(b.name)), failures } } -export async function listInterfaceCatalog(serverId: number): Promise { - const state = await getInterfaceCatalogState(serverId) +export function projectLegacyInterfaceCatalog(state: InterfaceCatalogState): CatalogInterface[] { return state.interfaces.filter(iface => !(iface.type === "ipsec" && iface.name === "ipsec-vpn" && !iface.peers?.length)).map(iface => ({ ...iface, running: iface.running ?? false, disabled: iface.disabled ?? false })) } +export async function listInterfaceCatalog(serverId: number): Promise { + return projectLegacyInterfaceCatalog(await getInterfaceCatalogState(serverId)) +} + export { parseRawInterfaces, mapRosInterfaceType } diff --git a/backend/src/routes/mcp-transport.ts b/backend/src/routes/mcp-transport.ts index f25cbb4b..8c2d48ea 100644 --- a/backend/src/routes/mcp-transport.ts +++ b/backend/src/routes/mcp-transport.ts @@ -38,6 +38,10 @@ export async function makeMcpServer(principal: McpPrincipal, signal?: AbortSigna * these same authorized action objects; runtime still revalidates live policy. */ function makeMcpServerWithBindings(principal: McpPrincipal, bindings: ReturnType, signal?: AbortSignal, flowOwner?: FlowReadResponseOwner, wireEra?: "legacy" | "modern", genericOwner?: McpInFlightOwner, sdkBudget?: SdkTerminalBudget): McpServer { const server = sdkBudget ? new SdkBudgetedMcpServer(sdkBudget) : new McpServer(MCP_SERVER_INFO) + if (bindings.length === 0) { + server.server.registerCapabilities({ tools: {} }) + server.server.setRequestHandler("tools/list", () => ({ tools: [] })) + } for (const action of bindings) { const descriptor = action.operation server.registerTool(descriptor.toolName, { diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index 4c972aee..60666f22 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -416,7 +416,8 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options await checkClaims(actorOf(request.authUser)) const fresh=await getConnection(connection.id) if(fresh.generation!==await accessGeneration() || fresh.policy.status!=="active" || fresh.policy.version!==metadata.access_scope.policyVersion || !hasPermission(fresh.policy.permissions,"mm:servers:read") || metadata.access_scope.serverIds.some(id=>!fresh.policy.scope.serverIds.includes(id)))throw new McpError("PERMISSION_DENIED","Доступ к файлу отозван") - return reply.type(artifact.media_type).header("Content-Disposition", `attachment; filename="${artifact.filename.replace(/[^a-zA-Z0-9._-]/g, "_")}"`).send(content) + return reply.type(artifact.media_type).header("Cache-Control", "private, no-store") + .header("Content-Disposition", `attachment; filename="${artifact.filename.replace(/[^a-zA-Z0-9._-]/g, "_")}"`).send(content) }) } diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 51700f54..1c48c697 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { hasPermission } from "../lib/permissions.js" import { interfaceCatalogStateSchema, type InterfaceCatalogState, @@ -74,7 +75,9 @@ export function createUsersRoutes(deps: { getCatalogState?: (serverId: number) = schema: { body: appUserCreateSchema }, }, async (req, reply) => { try { - const user = await createUser(req.body as AppUserCreate) + const user = await createUser(req.body as AppUserCreate, { + canManagePermissions: !req.authUser || hasPermission(req.authUser.permissions, "mm:settings:admin"), + }) return reply.status(201).send({ user }) } catch (err) { return sendServiceError(reply, err) @@ -86,7 +89,9 @@ export function createUsersRoutes(deps: { getCatalogState?: (serverId: number) = }, async (req, reply) => { const { id } = req.params as { id: string } try { - const user = await updateUser(id, req.body as AppUserUpdate) + const user = await updateUser(id, req.body as AppUserUpdate, { + canManagePermissions: !req.authUser || hasPermission(req.authUser.permissions, "mm:settings:admin"), + }) return reply.send({ user }) } catch (err) { return sendServiceError(reply, err) diff --git a/backend/src/scripts/mcp-stdio.ts b/backend/src/scripts/mcp-stdio.ts index d208ab6d..466f5ed9 100644 --- a/backend/src/scripts/mcp-stdio.ts +++ b/backend/src/scripts/mcp-stdio.ts @@ -1,5 +1,4 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" -import { fromJsonSchema } from "@modelcontextprotocol/server" import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio" import { createStdioSdkTerminalGuard, StdioSdkBudgetedMcpServer } from "../modules/mcp/stdio-terminal-budget.js" @@ -16,17 +15,14 @@ const wire = new StdioServerTransport() guard.decorateWire(wire) const handle = serveStdio(async () => { const server = new StdioSdkBudgetedMcpServer(guard) - let cursor: string | undefined - do { - const page = await remote.listTools(cursor ? { cursor } : undefined) - for (const tool of page.tools) server.registerTool(tool.name, { - title: tool.title, description: tool.description, inputSchema: fromJsonSchema(tool.inputSchema as Parameters[0]), annotations: tool.annotations, - }, async (input, context) => { - guard.protectCallback(context.mcpReq.id) - return remote.callTool({ name: tool.name, arguments: input as Record }) - }) - cursor = page.nextCursor - } while (cursor) + server.server.registerCapabilities({ tools: {} }) + // The remote server supplies current policy and validates tool arguments. + server.server.setRequestHandler("tools/list", ({ params }, context) => + remote.listTools({ cursor: params?.cursor }, { signal: context.mcpReq.signal })) + server.server.setRequestHandler("tools/call", ({ params }, context) => { + guard.protectCallback(context.mcpReq.id) + return remote.callTool({ name: params.name, arguments: params.arguments }) + }) return server }, { transport: wire, onerror: () => process.stderr.write("Ошибка связи с MCP; проверьте доступ и журнал обращений\n") }) process.once("SIGTERM", () => { void Promise.all([handle.close(), remote.close()]) }) diff --git a/backend/src/services/fixtures/vrf-bridge-delete-ll-v33.json b/backend/src/services/fixtures/vrf-bridge-delete-ll-v33.json new file mode 100644 index 00000000..6bb003a1 --- /dev/null +++ b/backend/src/services/fixtures/vrf-bridge-delete-ll-v33.json @@ -0,0 +1,223 @@ +{ + "kind": "V33 selected loop bridge DELETE observation; not raw17", + "serverId": 2, + "inputs": { + "beforeSha256": "09a4a1a16dee2554bf809d88328b276f0289e81e29fa94b18f7a61ffa709d0e4", + "afterSha256": "b5116e76452573cb0b6e532afb81d16ffc8360710da5e59ac40da67341e1adae" + }, + "limitations": [ + "Safe projected selected rows only; omitted native fields unavailable", + "Tests reconstruct main VRF/table/singleton settings and restrict target membership to this bridge", + "Full admission/classifier on reconstructed test graph is synthetic, not full actual raw17 replay" + ], + "before": { + "bridge": [ + { + ".id": "*2C", + "actual-mtu": "1500", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "dynamic": "false", + "mac-address": "82:97:C3:74:4C:A5", + "mtu": "auto", + "name": "mcp-route-3efd451365d2-loop", + "protocol-mode": "none", + "running": "true" + } + ], + "interface_inventory": [ + { + ".id": "*2C", + "actual-mtu": "1500", + "caps": " ARP DHCP USERARP VLAN IS_BRIDGE MESH", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "dynamic": "false", + "flags": " UP BROADCAST RUNNING MULTICAST LOWER_UP (0x11043)", + "fp-rps-drop": "0", + "id": "44", + "ifindex": "46", + "ifname": "br0", + "last-link-up-time": "2026-11-05 19:52:16", + "link-downs": "0", + "mac-address": "82:97:C3:74:4C:A5", + "mtu": "auto", + "name": "mcp-route-3efd451365d2-loop", + "running": "true", + "status": "--LERUI-O-----------V---", + "type": "bridge", + "vrf": "mcp-route-3efd451365d2-vrf" + }, + { + ".id": "*2D", + "actual-mtu": "65536", + "caps": "", + "disabled": "false", + "flags": " UP RUNNING NOARP MASTER LOWER_UP (0x104c1)", + "fp-rps-drop": "0", + "id": "45", + "ifindex": "47", + "ifname": "vrf1", + "last-link-up-time": "2026-11-05 19:55:16", + "link-downs": "0", + "mac-address": "7A:F0:19:DD:88:39", + "mtu": "65536", + "name": "mcp-route-3efd451365d2-vrf", + "running": "true", + "status": "--LERU--O---------------", + "type": "vrf", + "vrf": "main" + } + ], + "vrf": [ + { + ".id": "*1", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "interfaces": "mcp-route-3efd451365d2-gre-a,mcp-route-3efd451365d2-gre-b,mcp-route-3efd451365d2-loop", + "name": "mcp-route-3efd451365d2-vrf" + } + ], + "ipv4_address": [ + { + ".id": "*31", + "actual-interface": "mcp-route-3efd451365d2-loop", + "address": "198.18.245.17/32", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "dynamic": "false", + "interface": "mcp-route-3efd451365d2-loop", + "invalid": "false", + "vrf": "mcp-route-3efd451365d2-vrf" + } + ], + "ipv6_address": [ + { + ".id": "*3D", + "actual-interface": "mcp-route-3efd451365d2-loop", + "address": "fe80::8097:c3ff:fe74:4ca5/64", + "auto-link-local": "true", + "disabled": "false", + "dynamic": "true", + "interface": "mcp-route-3efd451365d2-loop", + "invalid": "false", + "link-local": "true", + "vrf": "main" + } + ], + "ipv4_route": [ + { + ".id": "*20145080", + "connect": "true", + "distance": "0", + "dst-address": "198.18.245.17/32", + "dynamic": "true", + "gateway": "mcp-route-3efd451365d2-loop@mcp-route-3efd451365d2-vrf", + "immediate-gw": "mcp-route-3efd451365d2-loop", + "local-address": "198.18.245.17%mcp-route-3efd451365d2-loop@mcp-route-3efd451365d2-vrf", + "routing-table": "mcp-route-3efd451365d2-vrf", + "scope": "10", + "target-scope": "5" + } + ], + "ipv6_route": [ + { + ".id": "*20145070", + "connect": "true", + "distance": "0", + "dst-address": "fe80::/64", + "dynamic": "true", + "gateway": "mcp-route-3efd451365d2-loop@mcp-route-3efd451365d2-vrf", + "immediate-gw": "mcp-route-3efd451365d2-loop", + "routing-table": "mcp-route-3efd451365d2-vrf", + "scope": "10", + "target-scope": "5" + } + ], + "routing_table": [ + { + ".id": "*1", + "dynamic": "true", + "fib": "", + "invalid": "false", + "name": "mcp-route-3efd451365d2-vrf" + } + ], + "bridge_port": [], + "bridge_vlan": [] + }, + "after": { + "bridge": [ + { + ".id": "*2C", + "actual-mtu": "1500", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "dynamic": "false", + "mac-address": "82:97:C3:74:4C:A5", + "mtu": "auto", + "name": "mcp-route-3efd451365d2-loop", + "protocol-mode": "none", + "running": "true" + } + ], + "interface_inventory": [ + { + ".id": "*2C", + "actual-mtu": "1500", + "caps": " ARP DHCP USERARP VLAN IS_BRIDGE MESH", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "dynamic": "false", + "flags": " UP BROADCAST RUNNING MULTICAST LOWER_UP (0x11043)", + "fp-rps-drop": "0", + "id": "44", + "ifindex": "46", + "ifname": "br0", + "last-link-up-time": "2026-11-05 19:52:16", + "link-downs": "0", + "mac-address": "82:97:C3:74:4C:A5", + "mtu": "auto", + "name": "mcp-route-3efd451365d2-loop", + "running": "true", + "status": "--LERUI-O---------------", + "type": "bridge", + "vrf": "main" + } + ], + "vrf": [], + "ipv4_address": [ + { + ".id": "*31", + "actual-interface": "mcp-route-3efd451365d2-loop", + "address": "198.18.245.17/32", + "comment": "mcp-route-3efd451365d2", + "disabled": "false", + "dynamic": "false", + "interface": "mcp-route-3efd451365d2-loop", + "invalid": "false", + "vrf": "main" + } + ], + "ipv6_address": [], + "ipv4_route": [ + { + ".id": "*201450D0", + "connect": "true", + "distance": "0", + "dst-address": "198.18.245.17/32", + "dynamic": "true", + "gateway": "mcp-route-3efd451365d2-loop", + "immediate-gw": "mcp-route-3efd451365d2-loop", + "local-address": "198.18.245.17%mcp-route-3efd451365d2-loop", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + } + ], + "ipv6_route": [], + "routing_table": [], + "bridge_port": [], + "bridge_vlan": [] + } +} diff --git a/backend/src/services/fixtures/vrf-gre-inverse-cycle-v32.json b/backend/src/services/fixtures/vrf-gre-inverse-cycle-v32.json new file mode 100644 index 00000000..c3d5f1c4 --- /dev/null +++ b/backend/src/services/fixtures/vrf-gre-inverse-cycle-v32.json @@ -0,0 +1,349 @@ +{ + "kind": "actual-selected-V32-guarded-inverse-pair-with-reconstructed-root-config", + "inputs": { + "preDetail": "12195049ae548776d47449a86f849ebf19776c9d3d562bb425f138c24a0a0922", + "postDetail": "d96200bd6590d69e484d4535f28b113bdd49944de161eadfe6ab4fc506affbe8", + "cleanupV2": "094f4b59569386149288f4696fbcd7105a0d049a035bc825f7753c1ffc537c88", + "receipt": "ebbe523caa1217ea4fab393b7d9238e23f164df8544fe6a29f3752400d81dd63" + }, + "before": { + "catalogs": { + "vrf": [ + { + ".id": "*1", + "name": "mcp-route-46e4f3b60294-vrf", + "interfaces": "mcp-route-46e4f3b60294-gre-a,mcp-route-46e4f3b60294-gre-b", + "disabled": "false" + }, + { + ".id": "*0", + "name": "main", + "interfaces": "all", + "disabled": "false" + } + ], + "interface_inventory": [ + { + ".id": "*30", + "name": "mcp-route-46e4f3b60294-gre-a", + "type": "gre-tunnel", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "vrf": "mcp-route-46e4f3b60294-vrf", + "caps": "", + "flags": " UP POINTOPOINT RUNNING NOARP LOWER_UP (0x100d1)", + "fp-rps-drop": "0", + "id": "48", + "ifindex": "49", + "ifname": "greip4-48", + "last-link-up-time": "2026-11-05 19:42:15", + "last-link-down-time": "2026-11-05 19:42:15", + "link-downs": "1", + "status": "--L-RUI-O-----------V---" + }, + { + ".id": "*31", + "name": "mcp-route-46e4f3b60294-gre-b", + "type": "gre-tunnel", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "vrf": "mcp-route-46e4f3b60294-vrf", + "caps": "", + "flags": " UP POINTOPOINT RUNNING NOARP LOWER_UP (0x100d1)", + "fp-rps-drop": "0", + "id": "49", + "ifindex": "50", + "ifname": "greip4-49", + "last-link-up-time": "2026-11-05 19:42:15", + "last-link-down-time": "2026-11-05 19:42:15", + "link-downs": "1", + "status": "--L-RUI-O-----------V---" + }, + { + ".id": "*32", + "name": "mcp-route-46e4f3b60294-vrf", + "type": "vrf", + "mac-address": "16:A1:FC:19:3A:0B", + "disabled": "false", + "running": "true", + "mtu": "65536", + "actual-mtu": "65536", + "vrf": "main", + "caps": "", + "flags": " UP RUNNING NOARP MASTER LOWER_UP (0x104c1)", + "fp-rps-drop": "0", + "id": "50", + "ifindex": "51", + "ifname": "vrf1", + "last-link-up-time": "2026-11-05 19:42:15", + "link-downs": "0", + "status": "--LERU--O---------------" + }, + { + ".id": "*2F", + "name": "mcp-route-46e4f3b60294-wg", + "type": "wg", + "disabled": "false", + "running": "true", + "mtu": "1420", + "actual-mtu": "1420", + "vrf": "main", + "caps": "", + "flags": " UP POINTOPOINT RUNNING NOARP LOWER_UP (0x100d1)", + "fp-rps-drop": "0", + "id": "47", + "ifindex": "48", + "ifname": "wg47", + "last-link-up-time": "2026-11-05 19:32:05", + "link-downs": "0", + "status": "--L-RUI-O---------------" + } + ], + "gre": [ + { + ".id": "*30", + "name": "mcp-route-46e4f3b60294-gre-a", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "local-address": "198.18.245.9", + "remote-address": "198.18.245.10", + "allow-fast-path": "false", + "clamp-tcp-mss": "true", + "keepalive": "10s,10", + "comment": "mcp-route-46e4f3b60294" + }, + { + ".id": "*31", + "name": "mcp-route-46e4f3b60294-gre-b", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "local-address": "198.18.245.13", + "remote-address": "198.18.245.14", + "allow-fast-path": "false", + "clamp-tcp-mss": "true", + "keepalive": "10s,10", + "comment": "mcp-route-46e4f3b60294" + } + ], + "ipv4_address": [ + { + ".id": "*3A", + "address": "198.18.245.9/30", + "interface": "mcp-route-46e4f3b60294-wg", + "actual-interface": "mcp-route-46e4f3b60294-wg", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "main", + "network": "198.18.245.8" + }, + { + ".id": "*3B", + "address": "198.18.245.13/30", + "interface": "mcp-route-46e4f3b60294-wg", + "actual-interface": "mcp-route-46e4f3b60294-wg", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "main", + "network": "198.18.245.12" + }, + { + ".id": "*3C", + "address": "198.18.245.1/30", + "interface": "mcp-route-46e4f3b60294-gre-a", + "actual-interface": "mcp-route-46e4f3b60294-gre-a", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "mcp-route-46e4f3b60294-vrf", + "network": "198.18.245.0" + }, + { + ".id": "*3D", + "address": "198.18.245.5/30", + "interface": "mcp-route-46e4f3b60294-gre-b", + "actual-interface": "mcp-route-46e4f3b60294-gre-b", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "mcp-route-46e4f3b60294-vrf", + "network": "198.18.245.4" + } + ], + "wireguard": [ + { + ".id": "*2F", + "name": "mcp-route-46e4f3b60294-wg" + } + ] + } + }, + "after": { + "catalogs": { + "vrf": [ + { + ".id": "*0", + "name": "main", + "interfaces": "all", + "disabled": "false" + } + ], + "interface_inventory": [ + { + ".id": "*30", + "name": "mcp-route-46e4f3b60294-gre-a", + "type": "gre-tunnel", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "vrf": "main", + "caps": "", + "flags": " UP POINTOPOINT RUNNING NOARP LOWER_UP (0x100d1)", + "fp-rps-drop": "0", + "id": "48", + "ifindex": "49", + "ifname": "greip4-48", + "last-link-up-time": "2026-11-05 20:30:42", + "last-link-down-time": "2026-11-05 20:30:41", + "link-downs": "2", + "status": "--L-RUI-O---------------" + }, + { + ".id": "*31", + "name": "mcp-route-46e4f3b60294-gre-b", + "type": "gre-tunnel", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "vrf": "main", + "caps": "", + "flags": " UP POINTOPOINT RUNNING NOARP LOWER_UP (0x100d1)", + "fp-rps-drop": "0", + "id": "49", + "ifindex": "50", + "ifname": "greip4-49", + "last-link-up-time": "2026-11-05 20:30:42", + "last-link-down-time": "2026-11-05 20:30:41", + "link-downs": "2", + "status": "--L-RUI-O---------------" + }, + { + ".id": "*2F", + "name": "mcp-route-46e4f3b60294-wg", + "type": "wg", + "disabled": "false", + "running": "true", + "mtu": "1420", + "actual-mtu": "1420", + "vrf": "main", + "caps": "", + "flags": " UP POINTOPOINT RUNNING NOARP LOWER_UP (0x100d1)", + "fp-rps-drop": "0", + "id": "47", + "ifindex": "48", + "ifname": "wg47", + "last-link-up-time": "2026-11-05 19:32:05", + "link-downs": "0", + "status": "--L-RUI-O---------------" + } + ], + "gre": [ + { + ".id": "*30", + "name": "mcp-route-46e4f3b60294-gre-a", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "local-address": "198.18.245.9", + "remote-address": "198.18.245.10", + "allow-fast-path": "false", + "clamp-tcp-mss": "true", + "keepalive": "10s,10", + "comment": "mcp-route-46e4f3b60294" + }, + { + ".id": "*31", + "name": "mcp-route-46e4f3b60294-gre-b", + "disabled": "false", + "running": "true", + "mtu": "1476", + "actual-mtu": "1476", + "local-address": "198.18.245.13", + "remote-address": "198.18.245.14", + "allow-fast-path": "false", + "clamp-tcp-mss": "true", + "keepalive": "10s,10", + "comment": "mcp-route-46e4f3b60294" + } + ], + "ipv4_address": [ + { + ".id": "*3A", + "address": "198.18.245.9/30", + "interface": "mcp-route-46e4f3b60294-wg", + "actual-interface": "mcp-route-46e4f3b60294-wg", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "main", + "network": "198.18.245.8" + }, + { + ".id": "*3B", + "address": "198.18.245.13/30", + "interface": "mcp-route-46e4f3b60294-wg", + "actual-interface": "mcp-route-46e4f3b60294-wg", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "main", + "network": "198.18.245.12" + }, + { + ".id": "*3C", + "address": "198.18.245.1/30", + "interface": "mcp-route-46e4f3b60294-gre-a", + "actual-interface": "mcp-route-46e4f3b60294-gre-a", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "main", + "network": "198.18.245.0" + }, + { + ".id": "*3D", + "address": "198.18.245.5/30", + "interface": "mcp-route-46e4f3b60294-gre-b", + "actual-interface": "mcp-route-46e4f3b60294-gre-b", + "dynamic": "false", + "disabled": "false", + "invalid": "false", + "vrf": "main", + "network": "198.18.245.4" + } + ], + "wireguard": [ + { + ".id": "*2F", + "name": "mcp-route-46e4f3b60294-wg" + } + ] + } + }, + "rootWireguardConfigurationReconstructed": true, + "fullRaw17": false, + "forwardCreateCycleProven": false, + "exclusiveCausalAttributionProven": false +} diff --git a/backend/src/services/fixtures/vrf-projection-v2.json b/backend/src/services/fixtures/vrf-projection-v2.json new file mode 100644 index 00000000..f3746194 --- /dev/null +++ b/backend/src/services/fixtures/vrf-projection-v2.json @@ -0,0 +1,811 @@ +{ + "kind": "genuine-pre-V3-output-with-synthetic-GRE-WG-peer", + "sourceProjectionSha256": "01366cbf771dec96af7441696763e19b0276cfb312df4715bc3bb0941cdd2b80", + "before": { + "projection": "raw-native-v1", + "serverId": 7, + "sourceProof": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "catalogs": { + "bridge": [], + "bridge_port": [], + "bridge_vlan": [], + "vrf": [ + { + ".id": "*0", + "name": "main", + "interfaces": "all", + "disabled": "false", + "builtin": "true" + } + ], + "ipv4_address": [ + { + ".id": "*A", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "address": "198.18.137.1/32", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "invalid": "false", + "network": "198.18.137.1", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*B", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "address": "198.18.137.2/32", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "invalid": "false", + "network": "198.18.137.2", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*ff20", + "interface": "gre-source-wg", + "address": "198.18.245.9/30", + "dynamic": "false", + "disabled": "false", + "vrf": "main" + }, + { + ".id": "*ff21", + "interface": "gre-source-wg", + "address": "198.18.245.13/30", + "dynamic": "false", + "disabled": "false", + "vrf": "main" + } + ], + "ipv6_address": [ + { + ".id": "*19", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "address": "fe80::2:c612:f50a/64", + "advertise": "false", + "auto-link-local": "true", + "deprecated": "false", + "disabled": "false", + "dynamic": "true", + "eui-64": "false", + "from-pool": "", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "invalid": "false", + "link-local": "true", + "no-dad": "false", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*1A", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "address": "fe80::2:c612:f50e/64", + "advertise": "false", + "auto-link-local": "true", + "deprecated": "false", + "disabled": "false", + "dynamic": "true", + "eui-64": "false", + "from-pool": "", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "invalid": "false", + "link-local": "true", + "no-dad": "false", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*1B", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "address": "fd6d:6370:2043:fbc2::1/128", + "advertise": "false", + "auto-link-local": "true", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "deprecated": "false", + "disabled": "false", + "dynamic": "false", + "eui-64": "false", + "from-pool": "", + "global": "true", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "invalid": "false", + "no-dad": "false", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*1C", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "address": "fd6d:6370:2043:fbc2::2/128", + "advertise": "false", + "auto-link-local": "true", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "deprecated": "false", + "disabled": "false", + "dynamic": "false", + "eui-64": "false", + "from-pool": "", + "global": "true", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "invalid": "false", + "no-dad": "false", + "slave": "false", + "vrf": "main" + } + ], + "ipv4_route": [ + { + ".id": "*20145060", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "198.18.137.1/32", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "inactive": "false", + "local-address": "198.18.137.1%mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*20145080", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "198.18.137.2/32", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "inactive": "false", + "local-address": "198.18.137.2%mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + } + ], + "ipv6_route": [ + { + ".id": "*20145070", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fd6d:6370:2043:fbc2::1/128", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "inactive": "false", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*20145090", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fd6d:6370:2043:fbc2::2/128", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "inactive": "false", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*20145040", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fe80::/64", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "inactive": "false", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*20145050", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fe80::/64", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "inactive": "false", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + } + ], + "routing_table": [ + { + ".id": "*0", + "name": "main", + "dynamic": "true", + "fib": "true" + } + ], + "ethernet": [], + "ip_filter": [], + "ipv6_filter": [], + "routing_rule": [], + "ip_settings": [ + {} + ], + "ipv6_settings": [ + {} + ], + "interface_inventory": [ + { + ".id": "*D", + "actual-mtu": "1500", + "caps": " ARP DHCP USERARP VLAN IS_BRIDGE MESH", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "flags": " UP BROADCAST RUNNING MULTICAST LOWER_UP (0x11043)", + "fp-rps-drop": "0", + "fp-rx-byte": "0", + "fp-rx-packet": "0", + "fp-tx-byte": "0", + "fp-tx-packet": "0", + "id": "13", + "ifindex": "16", + "ifname": "br0", + "l2mtu": "65535", + "last-link-up-time": "2026-10-24 15:32:46", + "link-downs": "0", + "mtu": "auto", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "running": "true", + "rx-byte": "0", + "rx-drop": "0", + "rx-error": "0", + "rx-packet": "0", + "status": "--LERUI-O---------------", + "tx-byte": "1194", + "tx-drop": "0", + "tx-error": "0", + "tx-packet": "11", + "tx-queue-drop": "0", + "type": "gre-tunnel", + "vrf": "main" + }, + { + ".id": "*E", + "actual-mtu": "1500", + "caps": " ARP DHCP USERARP VLAN IS_BRIDGE MESH", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "flags": " UP BROADCAST RUNNING MULTICAST LOWER_UP (0x11043)", + "fp-rps-drop": "0", + "fp-rx-byte": "0", + "fp-rx-packet": "0", + "fp-tx-byte": "0", + "fp-tx-packet": "0", + "id": "14", + "ifindex": "17", + "ifname": "br1", + "l2mtu": "65535", + "last-link-up-time": "2026-10-24 15:33:04", + "link-downs": "0", + "mtu": "auto", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "running": "true", + "rx-byte": "0", + "rx-drop": "0", + "rx-error": "0", + "rx-packet": "0", + "status": "--LERUI-O---------------", + "tx-byte": "1194", + "tx-drop": "0", + "tx-error": "0", + "tx-packet": "11", + "tx-queue-drop": "0", + "type": "gre-tunnel", + "vrf": "main" + }, + { + ".id": "*ff11", + "name": "gre-source-wg", + "type": "wg", + "vrf": "main" + } + ], + "interface_list_members": [], + "gre": [ + { + ".id": "*D", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "local-address": "198.18.245.9", + "remote-address": "198.18.245.10" + }, + { + ".id": "*E", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "local-address": "198.18.245.13", + "remote-address": "198.18.245.14" + } + ], + "wireguard": [ + { + ".id": "*ff11", + "name": "gre-source-wg", + "private-key": "TEST_PRIVATE_KEY" + } + ], + "wireguard_peer": [ + { + ".id": "*ff31", + "interface": "gre-source-wg", + "public-key": "TEST_PUBLIC_KEY", + "preshared-key": "TEST_PSK", + "endpoint-address": "192.0.2.2", + "endpoint-port": "55101", + "current-endpoint-address": "192.0.2.2", + "current-endpoint-port": "55101", + "allowed-address": "198.18.245.10/32", + "persistent-keepalive": "10s", + "disabled": "false", + "running": "true", + "rx": "100", + "tx": "200", + "last-handshake": "1s", + "opaque": "configuration" + } + ] + } + }, + "after": { + "projection": "raw-native-v1", + "serverId": 7, + "sourceProof": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "catalogs": { + "bridge": [], + "bridge_port": [], + "bridge_vlan": [], + "vrf": [ + { + ".id": "*4", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "interfaces": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v" + }, + { + ".id": "*0", + "name": "main", + "interfaces": "all", + "disabled": "false", + "builtin": "true" + } + ], + "ipv4_address": [ + { + ".id": "*A", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "address": "198.18.137.1/32", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "invalid": "false", + "network": "198.18.137.1", + "slave": "false", + "vrf": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v" + }, + { + ".id": "*B", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "address": "198.18.137.2/32", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "invalid": "false", + "network": "198.18.137.2", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*ff20", + "interface": "gre-source-wg", + "address": "198.18.245.9/30", + "dynamic": "false", + "disabled": "false", + "vrf": "main" + }, + { + ".id": "*ff21", + "interface": "gre-source-wg", + "address": "198.18.245.13/30", + "dynamic": "false", + "disabled": "false", + "vrf": "main" + } + ], + "ipv6_address": [ + { + ".id": "*1A", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "address": "fe80::2:c612:f50e/64", + "advertise": "false", + "auto-link-local": "true", + "deprecated": "false", + "disabled": "false", + "dynamic": "true", + "eui-64": "false", + "from-pool": "", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "invalid": "false", + "link-local": "true", + "no-dad": "false", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*1B", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "address": "fd6d:6370:2043:fbc2::1/128", + "advertise": "false", + "auto-link-local": "true", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "deprecated": "false", + "disabled": "false", + "dynamic": "false", + "eui-64": "false", + "from-pool": "", + "global": "true", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "invalid": "false", + "no-dad": "false", + "slave": "false", + "vrf": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v" + }, + { + ".id": "*1C", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "address": "fd6d:6370:2043:fbc2::2/128", + "advertise": "false", + "auto-link-local": "true", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "deprecated": "false", + "disabled": "false", + "dynamic": "false", + "eui-64": "false", + "from-pool": "", + "global": "true", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "invalid": "false", + "no-dad": "false", + "slave": "false", + "vrf": "main" + }, + { + ".id": "*1D", + "actual-interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "address": "fe80::2:c612:f50a/64", + "advertise": "false", + "auto-link-local": "true", + "deprecated": "false", + "disabled": "false", + "dynamic": "true", + "eui-64": "false", + "from-pool": "", + "interface": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "invalid": "false", + "link-local": "true", + "no-dad": "false", + "slave": "false", + "vrf": "main" + } + ], + "ipv4_route": [ + { + ".id": "*20145080", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "198.18.137.2/32", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "inactive": "false", + "local-address": "198.18.137.2%mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*201450B0", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "198.18.137.1/32", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a@mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "inactive": "false", + "local-address": "198.18.137.1%mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a@mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "routing-table": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "scope": "10", + "target-scope": "5" + } + ], + "ipv6_route": [ + { + ".id": "*20145090", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fd6d:6370:2043:fbc2::2/128", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "inactive": "false", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*20145050", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fe80::/64", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "inactive": "false", + "routing-table": "main", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*201450C0", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fd6d:6370:2043:fbc2::1/128", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a@mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "inactive": "false", + "routing-table": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "scope": "10", + "target-scope": "5" + }, + { + ".id": "*20145040", + "active": "true", + "connect": "true", + "distance": "0", + "dst-address": "fe80::/64", + "dynamic": "true", + "gateway": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a@mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "immediate-gw": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "inactive": "false", + "routing-table": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "scope": "10", + "target-scope": "5" + } + ], + "routing_table": [ + { + ".id": "*0", + "name": "main", + "dynamic": "true", + "fib": "true" + }, + { + ".id": "*4", + "dynamic": "true", + "fib": "", + "invalid": "false", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v" + } + ], + "ethernet": [], + "ip_filter": [], + "ipv6_filter": [], + "routing_rule": [], + "ip_settings": [ + {} + ], + "ipv6_settings": [ + {} + ], + "interface_inventory": [ + { + ".id": "*D", + "actual-mtu": "1500", + "caps": " ARP DHCP USERARP VLAN IS_BRIDGE MESH", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "flags": " UP BROADCAST RUNNING MULTICAST LOWER_UP (0x11043)", + "fp-rps-drop": "0", + "fp-rx-byte": "0", + "fp-rx-packet": "0", + "fp-tx-byte": "0", + "fp-tx-packet": "0", + "id": "13", + "ifindex": "16", + "ifname": "br0", + "l2mtu": "65535", + "last-link-up-time": "2026-10-24 15:32:46", + "link-downs": "0", + "mtu": "auto", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "running": "true", + "rx-byte": "0", + "rx-drop": "0", + "rx-error": "0", + "rx-packet": "0", + "status": "--LERUI-O-----------V---", + "tx-byte": "2146", + "tx-drop": "0", + "tx-error": "0", + "tx-packet": "19", + "tx-queue-drop": "0", + "type": "gre-tunnel", + "vrf": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v" + }, + { + ".id": "*E", + "actual-mtu": "1500", + "caps": " ARP DHCP USERARP VLAN IS_BRIDGE MESH", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797", + "disabled": "false", + "dynamic": "false", + "flags": " UP BROADCAST RUNNING MULTICAST LOWER_UP (0x11043)", + "fp-rps-drop": "0", + "fp-rx-byte": "0", + "fp-rx-packet": "0", + "fp-tx-byte": "0", + "fp-tx-packet": "0", + "id": "14", + "ifindex": "17", + "ifname": "br1", + "l2mtu": "65535", + "last-link-up-time": "2026-10-24 15:33:04", + "link-downs": "0", + "mtu": "auto", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "running": "true", + "rx-byte": "0", + "rx-drop": "0", + "rx-error": "0", + "rx-packet": "0", + "status": "--LERUI-O---------------", + "tx-byte": "1194", + "tx-drop": "0", + "tx-error": "0", + "tx-packet": "11", + "tx-queue-drop": "0", + "type": "gre-tunnel", + "vrf": "main" + }, + { + ".id": "*F", + "actual-mtu": "65536", + "caps": "", + "disabled": "false", + "flags": " UP RUNNING NOARP MASTER LOWER_UP (0x104c1)", + "fp-rps-drop": "0", + "fp-rx-byte": "0", + "fp-rx-packet": "0", + "fp-tx-byte": "0", + "fp-tx-packet": "0", + "id": "15", + "ifindex": "18", + "ifname": "vrf4", + "last-link-up-time": "2026-10-24 15:34:48", + "link-downs": "0", + "mac-address": "FA:D1:FD:E0:45:DD", + "mtu": "65536", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "running": "true", + "rx-byte": "0", + "rx-drop": "0", + "rx-error": "0", + "rx-packet": "0", + "status": "--LERU--O---------------", + "tx-byte": "0", + "tx-drop": "0", + "tx-error": "0", + "tx-packet": "0", + "tx-queue-drop": "0", + "type": "vrf", + "vrf": "main" + }, + { + ".id": "*ff11", + "name": "gre-source-wg", + "type": "wg", + "vrf": "main" + } + ], + "interface_list_members": [], + "gre": [ + { + ".id": "*D", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "local-address": "198.18.245.9", + "remote-address": "198.18.245.10" + }, + { + ".id": "*E", + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-b", + "local-address": "198.18.245.13", + "remote-address": "198.18.245.14" + } + ], + "wireguard": [ + { + ".id": "*ff11", + "name": "gre-source-wg", + "private-key": "TEST_PRIVATE_KEY" + } + ], + "wireguard_peer": [ + { + ".id": "*ff31", + "interface": "gre-source-wg", + "public-key": "TEST_PUBLIC_KEY", + "preshared-key": "TEST_PSK", + "endpoint-address": "192.0.2.2", + "endpoint-port": "55101", + "current-endpoint-address": "192.0.2.2", + "current-endpoint-port": "55101", + "allowed-address": "198.18.245.10/32", + "persistent-keepalive": "10s", + "disabled": "false", + "running": "true", + "rx": "100", + "tx": "200", + "last-handshake": "1s", + "opaque": "configuration" + } + ] + } + }, + "binding": { + "serverId": 7, + "sourceProof": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "actorId": "legacy-human", + "planId": "legacy-plan" + }, + "request": { + "action": "create", + "fields": { + "name": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-v", + "interfaces": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797-a", + "comment": "mcp-test-vrf-companion-3890a4fa-2f69-4fcc-b11e-5ea4d1245797" + }, + "placeBeforeId": "*0" + }, + "scope": { + "serverId": 7, + "sourceProof": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "actorId": "legacy-human", + "nativeId": "*4" + }, + "now": 1000, + "secretFill": 7, + "acknowledgedId": "*4", + "admissionV2": "eyJiaW5kaW5nIjp7ImFjdG9ySWQiOiJsZWdhY3ktaHVtYW4iLCJwbGFuSWQiOiJsZWdhY3ktcGxhbiIsInNlcnZlcklkIjo3LCJzb3VyY2VQcm9vZiI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEifSwiY29tcGFuaW9uRXhlY3V0aW9uIjoicmVxdWlyZXMtbmF0aXZlLXRyYW5zaXRpb24tcHJvb2YiLCJleHBpcmVzQXQiOjMwMTAwMCwiaXNzdWVkQXQiOjEwMDAsIm1lbWJlcnMiOlt7ImFkZHJlc3NlcyI6W3siY29uZmlndXJhdGlvbkhhc2giOiI1MDI3ZDZiZTMwN2ZlYTYxYTY3NTE2N2FhMjExMjUwZDc1NTNhMTViNThjY2FmMDAxNDE3YzIyYjgzOTdkOGVmIiwiaWQiOiIqQSIsInBhcmVudElkIjoiKkQiLCJyZXNvdXJjZSI6ImlwdjRfYWRkcmVzcyJ9LHsiY29uZmlndXJhdGlvbkhhc2giOiI2MDU1NzBmYmE1MzBhYmQ3YzM5NjFiODhmMWZkN2YyNjhiNzA1MTUxZTlhNDY1NzA2MmFiMWVhMmY4MmE3NTU5IiwiaWQiOiIqZmYyMCIsInBhcmVudElkIjoiKmZmMTEiLCJyZXNvdXJjZSI6ImlwdjRfYWRkcmVzcyJ9LHsiY29uZmlndXJhdGlvbkhhc2giOiI4YjJmZjMzYmE4MTUxNDQyZmI0Yjk5Y2Q5Y2YxMWVkOWJlYjE0ZTI4NTgxNTJjZDI0YTk0ZDdlYThmY2ViM2IxIiwiaWQiOiIqZmYyMSIsInBhcmVudElkIjoiKmZmMTEiLCJyZXNvdXJjZSI6ImlwdjRfYWRkcmVzcyJ9LHsiY29uZmlndXJhdGlvbkhhc2giOiIxMTIwZGE0OTViMjVmN2RhN2U0NDQwNzUxMjNhOWU0NzhhZjlhOTFjYjk5ZTE2MTcwNDhhNDAwZTQ1MzYzZjRmIiwiaWQiOiIqMTkiLCJwYXJlbnRJZCI6IipEIiwicmVzb3VyY2UiOiJpcHY2X2FkZHJlc3MifSx7ImNvbmZpZ3VyYXRpb25IYXNoIjoiNTdlMWZiYmMwOGMzZTNiYWNkZTg0YjcyZDM2NWQ3YjVjNDY4MTEyNTM0MWZhNDM1NTI4ZmRmMzAzOTllMDdhMiIsImlkIjoiKjFCIiwicGFyZW50SWQiOiIqRCIsInJlc291cmNlIjoiaXB2Nl9hZGRyZXNzIn1dLCJjb25maWd1cmF0aW9uSGFzaCI6ImVjZDYxMDllZTJkMzQ1NjAxOGM3Mzg2ZmZkY2FmZTRjNzMyZDZjMDdlMGVhMDNhOTVjOTYwMWM4ZTczYTM2ZjAiLCJpZCI6IipEIiwibmFtZSI6Im1jcC10ZXN0LXZyZi1jb21wYW5pb24tMzg5MGE0ZmEtMmY2OS00ZmNjLWIxMWUtNWVhNGQxMjQ1Nzk3LWEiLCJwYXJlbnRzIjpbeyJjb25maWd1cmF0aW9uSGFzaCI6ImNhYjg0MzdlYzY5OWE0N2Y1NjI1YTk2ZmQ0NzM4YzNiNDhlZDNhNTI4YjNlMTZkMzM3NjE5YjY2YmUwOWY2YjMiLCJjb25maWd1cmF0aW9uSWQiOiIqRCIsImlkIjoiKkQiLCJuYW1lIjoibWNwLXRlc3QtdnJmLWNvbXBhbmlvbi0zODkwYTRmYS0yZjY5LTRmY2MtYjExZS01ZWE0ZDEyNDU3OTctYSIsInJlc291cmNlIjoiZ3JlIn0seyJjb25maWd1cmF0aW9uSGFzaCI6ImZiNGExZmMyZWVjYzZlYWRmM2U0Y2YwNDE0ZjVhMDMwNjI3ODkxNjY1NWYxMDVjOGM5YmQwODU5ODU2ODdmNzUiLCJjb25maWd1cmF0aW9uSWQiOiIqZmYxMSIsImlkIjoiKmZmMTEiLCJuYW1lIjoiZ3JlLXNvdXJjZS13ZyIsInJlc291cmNlIjoid2lyZWd1YXJkIn1dfV0sInJlcXVlc3QiOnsiYWN0aW9uIjoiY3JlYXRlIiwiZmllbGRzIjp7ImNvbW1lbnQiOiJtY3AtdGVzdC12cmYtY29tcGFuaW9uLTM4OTBhNGZhLTJmNjktNGZjYy1iMTFlLTVlYTRkMTI0NTc5NyIsImludGVyZmFjZXMiOiJtY3AtdGVzdC12cmYtY29tcGFuaW9uLTM4OTBhNGZhLTJmNjktNGZjYy1iMTFlLTVlYTRkMTI0NTc5Ny1hIiwibmFtZSI6Im1jcC10ZXN0LXZyZi1jb21wYW5pb24tMzg5MGE0ZmEtMmY2OS00ZmNjLWIxMWUtNWVhNGQxMjQ1Nzk3LXYifSwicGxhY2VCZWZvcmVJZCI6IiowIn0sInNuYXBzaG90SGFzaCI6IjYxZDRjZmM2MTEyNjdlYjgwMWUyMTJhOTk1ZjE2NTY4ZGQyZjFhOWNhZWU0M2FmN2JiNmQ1MTIxYzgyM2UxYjIiLCJ0aWNrZXRJZCI6ImNlNTI1MWViLTUwY2ItNDU2YS05ZjM0LTFmNWE5YjIwZTBhMyIsInZlcnNpb24iOjJ9.962f7d4246e57f9e660c3e250fbd088e7b73b3ddd5863b5456004fc53a8f6908", + "checkpointProofV1": "eyJ2ZXJzaW9uIjoyLCJzY29wZSI6eyJzZXJ2ZXJJZCI6Nywic291cmNlUHJvb2YiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwiYWN0b3JJZCI6ImxlZ2FjeS1odW1hbiIsIm5hdGl2ZUlkIjoiKjQifSwiaGlzdG9yeSI6eyJzbmFwc2hvdCI6eyJraW5kIjoiY29uZmlndXJhdGlvbi1zaGEyNTYtdjEiLCJzZXJ2ZXJJZCI6Nywic291cmNlUHJvb2YiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwiY2F0YWxvZ0RpZ2VzdHMiOnsiYnJpZGdlIjoiMzlmNzdkYjVlMzdlM2IzMGRiMjlhZTU3MjBkYTFjZTk1ODdkNzBlM2M4NTEyNmJhMTA2ZjJmYzM1NjI5MzUyNyIsImJyaWRnZV9wb3J0IjoiYWExY2IwZjI0ZjE5ZWVlYThiMTZkNjU4NWY3Mzg4ZTQ4MzI3NTczMjY3NmJhYzJlZTcxMWMxMGFmNmE3YjI2NCIsImJyaWRnZV92bGFuIjoiNzZjYzJmMTM5Nzk4Mzk0ODEwZDBkOTA5MzMzNmZmYzI1ZWMxOWMzNzM3ZTU5ZDgxYmNmMTJiM2NjNmZhMWMyOCIsImV0aGVybmV0IjoiZmYxOWVkYWIxODFkYzUyYmUyYTg3M2UyOGExYTUzODA5YTU1YWMwYjgwY2M3YTQ4MTIzNWM2NWU2ZGZkNTk1YyIsImdyZSI6ImI4OTEyNzA4YjAzNDk4NzA4NjQ4NTMzMGEzMjVlYTMzNWFiYjhkMmY0OWQyOGNkNjFhMjVmOTYzZDg1NDljYmYiLCJpbnRlcmZhY2VfaW52ZW50b3J5IjoiYmEzZWVlOGQxZGZkODA4MmUxZjZlY2Y5ZjdlYzA4NDFkNWNhMDc1ZmVkZTdiYmNmNzYyMzZjMmVmY2U0MmY4ZiIsImludGVyZmFjZV9saXN0X21lbWJlcnMiOiI1ODlmMmNkMzA5OWY1MWE3MzM4OGRkMzQxZGRjOWY4MmIxMTIyNjg4NDgxZTdhMjcxOTk5OWUxODYyZWFjZDkwIiwiaXBfZmlsdGVyIjoiNWI2NmI1YmZmYzNkOGU0MjVlMjRlNmJmYTNkYjE2MDkyYmQ3MWFmODVlMjc3NGFjNzFhOWE4YTk4ZGU0ZDVjOSIsImlwX3NldHRpbmdzIjoiZjc1NTNjODdiZThjZmViMTNhNWY1MTIwMjliZGEwZmMzNGFkMDUzY2UyZjcxMjg5ZTIwODk0OTI0MDNhMzliNyIsImlwdjRfYWRkcmVzcyI6ImI0YTQyYTQzN2NlZTgzYmZlY2RjNDQwZWVmNGEwNDA1NWY5MzYyNzI5M2VjNmExMTMyOWMyMWFjYjNhNzJhMWMiLCJpcHY0X3JvdXRlIjoiYTZiM2JlZTQyMWJlYzEyNzViNThlODMwYWY4MTY0NjI1N2NhOWRjNGY2YjY3NmE0ZTg0YzQxOTk4NDU3NzJlOCIsImlwdjZfYWRkcmVzcyI6ImY1YTJlMWFjN2NhNThhMmYxMTFlN2I0ZmFmMmZmNTE4MmNhM2Q0YzRiYjM3MWExZDRjODAyY2EzNjk0NTYzZWUiLCJpcHY2X2ZpbHRlciI6IjIzNzVmMDJmMDhlZTc2NjdlYWY5OWE3OTQwZjA3ZWVmOGI4OTMwNmIwMGI0YzQ5NTU4ODc3ZTM2YmMwN2Q2ZjQiLCJpcHY2X3JvdXRlIjoiOGY0ZDA3NjY0NzI0NmQyY2IxNTg2NTg4NjE5ZjUzNWQ0NWMwMTczYWQyOWRkYzNlZjExNmI0NjViYjY4NGUyMCIsImlwdjZfc2V0dGluZ3MiOiIzZDVhZWFjYzgxZjZjMTAxYzk5MTFmZmE5NmZjZmY5YmQ2MDM0M2UxZDFkNTlmNTRkNTNjZmJlM2JjMzYxYjIwIiwicm91dGluZ19ydWxlIjoiMDY3MGQ1OTIzMGVkNmJhNTczYjhlMzkwMDdiODJiMGI2ODNhMDkwYmFmZTY1YTBmNzEyMzBhZWZjYjhhZDhhMiIsInJvdXRpbmdfdGFibGUiOiJlOTMzMWRiMjU2ODczNjIwZmRkY2NmYzk2MmE3MjlmNjU5NjU2MzhjZjBiZWJiYzRmYTg0MjdlMGRiYjQ0OTEwIiwidnJmIjoiMzQ5OTlkZTIyM2FjNDU5MDc2OGUwNzY3ODE3NmNkMTVmYTA4OTgxMzRlYTU0ZTlmNDhmZWJkYTA3NTI5OGJiMyIsIndpcmVndWFyZCI6ImFjZTYyZGIyNTBlYjkxOGRjOWY0YWI3MzU2N2U5ODRhY2UyYjE2ZDhhN2U0OWIzYmM3MGY1MWQ1NmJlNWI0MzkiLCJ3aXJlZ3VhcmRfcGVlciI6IjBlOTg3MmQ5NWQzNTAwMTRkNjgzN2EyZjk0YTY1NzczYzA3NDZiMzVmMzJlNWM3ZmYyNzBlNjZlNzE0OGNlZTcifX0sInZyZklkIjoiKjQiLCJhY3RpdmVWaXJ0dWFsIjp7Ii5pZCI6IipGIiwiYWN0dWFsLW10dSI6IjY1NTM2IiwiY2FwcyI6IiIsImRpc2FibGVkIjoiZmFsc2UiLCJmbGFncyI6IiBVUCBSVU5OSU5HIE5PQVJQIE1BU1RFUiBMT1dFUl9VUCAoMHgxMDRjMSkiLCJmcC1ycHMtZHJvcCI6IjAiLCJmcC1yeC1ieXRlIjoiMCIsImZwLXJ4LXBhY2tldCI6IjAiLCJmcC10eC1ieXRlIjoiMCIsImZwLXR4LXBhY2tldCI6IjAiLCJpZCI6IjE1IiwiaWZpbmRleCI6IjE4IiwiaWZuYW1lIjoidnJmNCIsImxhc3QtbGluay11cC10aW1lIjoiMjAyNi0xMC0yNCAxNTozNDo0OCIsImxpbmstZG93bnMiOiIwIiwibWFjLWFkZHJlc3MiOiJGQTpEMTpGRDpFMDo0NTpERCIsIm10dSI6IjY1NTM2IiwibmFtZSI6Im1jcC10ZXN0LXZyZi1jb21wYW5pb24tMzg5MGE0ZmEtMmY2OS00ZmNjLWIxMWUtNWVhNGQxMjQ1Nzk3LXYiLCJydW5uaW5nIjoidHJ1ZSIsInJ4LWJ5dGUiOiIwIiwicngtZHJvcCI6IjAiLCJyeC1lcnJvciI6IjAiLCJyeC1wYWNrZXQiOiIwIiwic3RhdHVzIjoiLS1MRVJVLS1PLS0tLS0tLS0tLS0tLS0tIiwidHgtYnl0ZSI6IjAiLCJ0eC1kcm9wIjoiMCIsInR4LWVycm9yIjoiMCIsInR4LXBhY2tldCI6IjAiLCJ0eC1xdWV1ZS1kcm9wIjoiMCIsInR5cGUiOiJ2cmYiLCJ2cmYiOiJtYWluIn0sImFic2VudExsIjpbXSwicHJvdmVuYW5jZSI6eyJtaXNzaW5nTWVtYmVycyI6W10sImdyZU1lbWJlcnMiOlt7Ii5pZCI6IipEIiwibmFtZSI6Im1jcC10ZXN0LXZyZi1jb21wYW5pb24tMzg5MGE0ZmEtMmY2OS00ZmNjLWIxMWUtNWVhNGQxMjQ1Nzk3LWEiLCJ0eXBlIjoiZ3JlLXR1bm5lbCIsImdyZVBhcmVudCI6eyJjb25maWd1cmF0aW9uIjp7Ii5pZCI6IipEIiwibmFtZSI6Im1jcC10ZXN0LXZyZi1jb21wYW5pb24tMzg5MGE0ZmEtMmY2OS00ZmNjLWIxMWUtNWVhNGQxMjQ1Nzk3LWEiLCJsb2NhbC1hZGRyZXNzIjoiMTk4LjE4LjI0NS45IiwicmVtb3RlLWFkZHJlc3MiOiIxOTguMTguMjQ1LjEwIn0sInNvdXJjZUFkZHJlc3MiOnsiLmlkIjoiKmZmMjAiLCJpbnRlcmZhY2UiOiJncmUtc291cmNlLXdnIiwiYWRkcmVzcyI6IjE5OC4xOC4yNDUuOS8zMCIsImR5bmFtaWMiOiJmYWxzZSIsImRpc2FibGVkIjoiZmFsc2UiLCJ2cmYiOiJtYWluIn0sInNvdXJjZUludmVudG9yeSI6eyIuaWQiOiIqZmYxMSIsIm5hbWUiOiJncmUtc291cmNlLXdnIiwidHlwZSI6IndnIn0sInNvdXJjZUNvbmZpZ3VyYXRpb24iOnsiLmlkIjoiKmZmMTEiLCJuYW1lIjoiZ3JlLXNvdXJjZS13ZyJ9LCJzb3VyY2VSZXNvdXJjZSI6IndpcmVndWFyZCJ9fV19fX0.5e08c0fe549e780a6f0662649078b173eda76e166d254f8565e645f2504915e7" +} diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index d3d9183c..4ef4f646 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -726,8 +726,8 @@ export async function listIpsecClientsForCatalog(serverId: number): Promise<{ }), ]) return { clients: mapClients(state) } - } catch (e) { - return { clients: [], error: e instanceof Error ? e.message : String(e) } + } catch { + return { clients: [], error: "Не удалось получить список клиентов IPsec." } } } diff --git a/backend/src/services/ospf-cost-optimization.ts b/backend/src/services/ospf-cost-optimization.ts index 05d80c79..53db63ad 100644 --- a/backend/src/services/ospf-cost-optimization.ts +++ b/backend/src/services/ospf-cost-optimization.ts @@ -5,6 +5,7 @@ import { readOspfIdentityCatalog } from "./ospf-identity-catalog.js" import { observedBoolean } from "./ospf-observed-values.js" import { ospfCostConnectionProof } from "./ospf-template-cost-write.js" import { readRoutingObservations } from "../modules/mcp/routing-observations.js" +import { latestRoutingMetric } from "../modules/mcp/routing-observations-model.js" import { createRoutingSourceContext, readRoutingEndpoints } from "../modules/mcp/routing-observation-source.js" import { fresh } from "../modules/mcp/routing-model.js" import { fingerprint, McpError } from "../modules/mcp/security.js" @@ -12,7 +13,7 @@ import { configurationRows, readRosSnapshot, resourceById, routerClient, type Ro import { ospfTable, requireOspfBfdSession } from "../modules/mcp/ospf-health.js" import type { ChangeEvidence } from "../modules/mcp/action-types.js" -export interface OspfRank { id: string; interface: string; currentCost: number; proposedCost: number; score: number; pingMs: number | null; lossPct: number | null; dlMbps: number | null; ulMbps: number | null; observationId: string; peerId: number; validUntil: string } +export interface OspfRank { id: string; interface: string; currentCost: number; proposedCost: number; score: number; pingMs: number | null; lossPct: number | null; dlMbps: number | null; ulMbps: number | null; observationId: string; bandwidthObservationId: string | null; peerId: number; validUntil: string } /** Seal a current source-bound snapshot only if it still matches the catalog * used for ranking/identity resolution. Never attach new provenance to old rows. */ @@ -87,16 +88,22 @@ export async function buildOspfCostOptimizationEvidence(input: Record ({ serverId: id, catalog }))) if (!owner) return [] const peer = owner.serverId - const observation = observations.find(observation => observation.srcServerId === serverId && observation.dstServerId === peer && observation.srcInterface === template.interface && observation.dstInterface === owner.interface && observation.family === input.family && observation.table === input.table) - if (!observation || !observation.contextVerified || !fresh(observation.pingAt, 300, now) || observation.pingMs === null || observation.lossPct === null || observation.lossPct > Number(input.maxLossPct) || Number(input.pingWeight) !== 100 && (!fresh(observation.bandwidthAt, 3600, now) || observation.dlMbps === null || observation.ulMbps === null)) return [] - const score = Number(input.pingWeight) / 100 * Math.max(0, 100 - observation.pingMs! * 0.6) + (1 - Number(input.pingWeight) / 100) * Math.min(100, ((observation.dlMbps ?? 0) + (observation.ulMbps ?? 0)) / 18) - const validUntil = Math.min(Date.parse(observation.pingAt!) + 300000, Number(input.pingWeight) === 100 ? Infinity : Date.parse(observation.bandwidthAt!) + 3600000) + const linkObservations = observations.filter(row => row.srcServerId === serverId && row.dstServerId === peer && row.srcInterface === template.interface && row.dstInterface === owner.interface && row.family === input.family && row.table === input.table) + const ping = latestRoutingMetric(linkObservations, "pingAt") + if (!ping?.contextVerified || !fresh(ping.pingAt, 300, now) || ping.pingMs === null || ping.lossPct === null || ping.lossPct > Number(input.maxLossPct)) return [] + const pingWeight = Number(input.pingWeight) / 100 + const bandwidth = pingWeight === 1 ? undefined : latestRoutingMetric(linkObservations, "bandwidthAt") + if (pingWeight !== 1 && (!bandwidth?.contextVerified || !fresh(bandwidth.bandwidthAt, 3600, now) || bandwidth.dlMbps === null || bandwidth.ulMbps === null)) return [] + const score = pingWeight * Math.max(0, 100 - ping.pingMs * 0.6) + (1 - pingWeight) * Math.min(100, ((bandwidth?.dlMbps ?? 0) + (bandwidth?.ulMbps ?? 0)) / 18) + const pingUntil = Date.parse(ping.pingAt!) + 300000 + const bandwidthUntil = bandwidth ? Date.parse(bandwidth.bandwidthAt!) + 3600000 : Infinity + const validUntil = Math.min(pingUntil, bandwidthUntil) const bfd = bfdFlags.get(template.id) ? requireOspfBfdSession(bfdSessions as unknown as Record[], matches[0].address, template.interface, String(input.table)) : null - return [{ template, owner, score, observation, observationId: observation.id, validUntil, peerId: peer!, neighborId: String(matches[0][".id"]), bfd }] + return [{ template, owner, score, ping, bandwidth, pingUntil, bandwidthUntil, validUntil, peerId: peer, neighborId: String(matches[0][".id"]), bfd }] }).sort((a, b) => b.score - a.score || a.template.interface.localeCompare(b.template.interface)) if (!ranked.length) throw new McpError("MEASUREMENT_UNKNOWN", "Нет однозначных шаблонов и свежих измерений нужного instance/family/VRF", 409) const optimalCost = (index: number) => index < Number(input.ecmpPaths) ? 10 : (index - Number(input.ecmpPaths) + 2) * 10 - const before = { ranking: ranked.map((item, index) => ({ id: item.template.id, interface: item.template.interface, currentCost: item.template.cost, proposedCost: optimalCost(index), score: item.score, pingMs: item.observation.pingMs, lossPct: item.observation.lossPct, dlMbps: item.observation.dlMbps, ulMbps: item.observation.ulMbps, observationId: item.observationId, peerId: item.peerId, peerInstance: item.owner.instanceId, peerVrf: item.owner.vrf, peerInterface: item.owner.interface, validUntil: new Date(item.validUntil).toISOString() })) } + const before = { ranking: ranked.map((item, index) => ({ id: item.template.id, interface: item.template.interface, currentCost: item.template.cost, proposedCost: optimalCost(index), score: item.score, pingMs: item.ping.pingMs, lossPct: item.ping.lossPct, dlMbps: item.bandwidth?.dlMbps ?? null, ulMbps: item.bandwidth?.ulMbps ?? null, observationId: item.ping.id, bandwidthObservationId: item.bandwidth?.id ?? null, peerId: item.peerId, peerInstance: item.owner.instanceId, peerVrf: item.owner.vrf, peerInterface: item.owner.interface, validUntil: new Date(item.validUntil).toISOString() })) } const steps: ChangeEvidence["steps"] = ranked.flatMap((item, index) => { const cost = optimalCost(index) return cost === item.template.cost ? [] : [{ kind: "ros" as const, step: { serverId, resource: "ospf_template", action: "update" as const, id: item.template.id, fields: { cost: String(cost) }, expectedCost: item.template.cost, expectedServerProof } }] @@ -108,7 +115,12 @@ export async function buildOspfCostOptimizationEvidence(input: Record = ranked.map(item => ({ serverId, resource: "ospf_neighbor", id: item.neighborId, field: "state", identity: { interface: item.template.interface, instance: String(neighbors.find(row => row[".id"] === item.neighborId)!.instance) } })) for (const item of ranked) if (item.bfd) healthChecks.push({ serverId, resource: "bfd_session", id: String(item.bfd[".id"]), field: "bfd-up", identity: Object.fromEntries(["remote-address", "local-address", "vrf", "interface"].filter(key => typeof item.bfd![key] === "string").map(key => [key, String(item.bfd![key])])) }) const bfdSnapshots = bfdRequired ? [await readRosSnapshot(serverId, "bfd_configuration", beforeRead), await capture(serverId, "bfd_session", bfdSessions)] : [] - const observationChecks = ranked.map(item => ({ id: item.observation.id, srcServerId: serverId, dstServerId: item.peerId, srcInterface: item.template.interface, family: String(input.family), table: String(input.table), fingerprint: fingerprint(item.observation), validUntil: new Date(item.validUntil).toISOString() })) + const observationChecks = ranked.flatMap[number]>(item => { + const link = { srcServerId: serverId, dstServerId: item.peerId, srcInterface: item.template.interface, dstInterface: item.owner.interface, family, table: String(input.table) } + const ping = { ...link, id: item.ping.id, metric: "pingAt" as const, fingerprint: fingerprint(item.ping), validUntil: new Date(item.pingUntil).toISOString() } + if (!item.bandwidth) return [ping] + return [ping, { ...link, id: item.bandwidth.id, metric: "bandwidthAt" as const, fingerprint: fingerprint(item.bandwidth), validUntil: new Date(item.bandwidthUntil).toISOString() }] + }) await beforeRead() return { before, observationChecks, ospfOwnerGraph: { family, sourceSecretProofs: sourceContext.expected, instances: [...ownerSnapshots.values()].map(row => ({ serverId: row.serverId, fingerprint: fingerprint({ native: row.fingerprint, identity: identityFingerprints.get(row.serverId) }) })) }, target: { scope: "servers", serverIds: dependencyIds }, objects: ranked.map(item => ({ kind: "ospf_template", id: item.template.id, serverId })), snapshots: [snapshot, await capture(serverId, "ospf_neighbor", neighbors), await capture(serverId, "ospf_instance", primaryIdentity.raw), await capture(serverId, "ospf_area", areas), ...dependencySnapshots, ...bfdSnapshots], steps, healthChecks, validUntil: new Date(Math.min(...ranked.map(item => item.validUntil))).toISOString(), warnings: ["Рейтинг локальных интерфейсов не доказывает глобальный оптимум.", "Неизвестные шаблоны не меняются.", "Full выбранных соседств и Up включённых BFD-сессий проверяются до и после cost-записей. Сквозная связность требует контрольных проб; после истечения измерений нужен новый план."], requiresHuman: true } } diff --git a/backend/src/services/partition-write-transaction.ts b/backend/src/services/partition-write-transaction.ts index a5d8e36e..7d7e2b0e 100644 --- a/backend/src/services/partition-write-transaction.ts +++ b/backend/src/services/partition-write-transaction.ts @@ -45,7 +45,7 @@ function remaining(deadlineAt: number) { * Its bounded server timeout also applies when a surrounding Drizzle tx owns * the lifecycle. The surrounding owner must roll back/destroy on rejection. */ -export async function admitPartitionWriter(client: Pick, options: Omit = {}): Promise { +export async function admitPartitionWriter(client: { query(text: string, values?: [number]): Promise }, options: Omit = {}): Promise { const current = settings(options) const check = async () => { if (current.signal?.aborted) throw new PartitionWriteError() diff --git a/backend/src/services/routing-filter-body.ts b/backend/src/services/routing-filter-body.ts index cab0ab62..273c3187 100644 --- a/backend/src/services/routing-filter-body.ts +++ b/backend/src/services/routing-filter-body.ts @@ -7,3 +7,9 @@ export function canonicalRoutingFilterBody(body: string) { export function routingFilterBodiesMatch(actual: unknown, expected: string) { return typeof actual === "string" && canonicalRoutingFilterBody(actual) === canonicalRoutingFilterBody(expected) } + +/** Compare complete managed bodies, preserving bytes outside the compiler's interface assignment. */ +export function managedRoutingFilterBodiesMatch(actual: unknown, expected: string) { + const normalize = (body: string) => body.replace(/^ set gw-interface "([A-Za-z_][A-Za-z0-9_.-]{0,99})";$/gm, " set gw-interface $1;") + return typeof actual === "string" && (actual === expected || normalize(actual) === normalize(expected)) +} diff --git a/backend/src/services/server-partition-maintenance-lease.ts b/backend/src/services/server-partition-maintenance-lease.ts index 1a8276e4..1736afb0 100644 --- a/backend/src/services/server-partition-maintenance-lease.ts +++ b/backend/src/services/server-partition-maintenance-lease.ts @@ -3,6 +3,9 @@ import { AsyncLocalStorage } from "node:async_hooks" import { PARTITION_SPECS } from "../db/partitions.js" import type { ServerPollSqlConnection } from "./server-poll-production.js" import type { ServerPollBoundaryCoordinator } from "./server-poll-boundary.js" +import type { ProbeStorageOptions, ProbeStorageSql } from "./server-probe-journal-maintenance.js" +import { createProbeStorageConnectionConnector } from "./server-probe-storage-connection.js" +import { runPollStorageTransaction } from "./server-poll-storage.js" export const SERVER_PARTITION_WRITER_LOCK=8723110 export interface ServerPartitionLeaseOptions {signal?:AbortSignal;deadlineAt:number;serverIds:number[]} @@ -48,11 +51,17 @@ ALTER TABLE public."${parent}" ENABLE ALWAYS TRIGGER server_partition_writer_gua ` interface Holder {connection:ServerPollSqlConnection;options:ServerPartitionLeaseOptions;active:boolean;accepting:boolean;released:boolean;tail:Promise;destroy:()=>void} interface TransactionScope {holder:Holder;active:boolean;readOnly:boolean;savepoint:number;tail:Promise;parent?:TransactionScope} +export interface ServerPollJournalOwner { + transaction(options:ProbeStorageOptions,authorize:()=>Promise,work:(sql:ProbeStorageSql)=>Promise):Promise + assertClosedForQuarantine():void +} +const journalOwners=new WeakSet() +export const isServerPollJournalOwner=(owner:ServerPollJournalOwner)=>journalOwners.has(owner) /** One backend owns the session exclusive lock AND every maintenance SQL * transaction. PostgreSQL shared advisory acquisition in its row triggers is * reentrant on that same backend. No GUC, pool patch or bypass token exists. */ export function createServerPartitionMaintenanceLease(deps:ServerPartitionMaintenanceLeaseDependencies){ - const now=deps.now??Date.now,held=new AsyncLocalStorage(),transactions=new AsyncLocalStorage() + const now=deps.now??Date.now,held=new AsyncLocalStorage(),transactions=new AsyncLocalStorage(),journal=new AsyncLocalStorage() const fail=(unknown=false):never=>{throw new ServerPartitionLeaseError(unknown)} const checkOptions=(options:ServerPartitionTransactionOptions)=>{if(!Number.isSafeInteger(options.deadlineAt)||options.deadlineAt<=now()||options.deadlineAt-now()>SERVER_POLL_WALL_BUDGET_MS||options.signal?.aborted)fail()} const local=(holder:Holder,options:ServerPartitionTransactionOptions)=>{ @@ -116,6 +125,7 @@ export function createServerPartitionMaintenanceLease(deps:ServerPartitionMainte const transaction=async(readOnly:boolean,fence:()=>Promise,options:ServerPartitionTransactionOptions,work:(query:ServerPartitionQuery)=>Promise):Promise=>{ checkOptions(options);const holder=held.getStore(),parent=transactions.getStore() if(holder){ + if(journal.getStore()===holder)fail() if(!holder.active||!holder.accepting&&!parent)fail() if(parent){ if(!parent.active||parent.holder!==holder||!readOnly)fail() @@ -135,6 +145,48 @@ export function createServerPartitionMaintenanceLease(deps:ServerPartitionMainte const standalone=await holderFor({...options,serverIds:[]}) try{return await transact(standalone,true,fence,options,work)}finally{if(!standalone.released){standalone.active=false;standalone.released=true;standalone.connection.release(false)}} } + const journalOwner:ServerPollJournalOwner=Object.freeze({ + async transaction(options:ProbeStorageOptions,authorize:()=>Promise,work:(sql:ProbeStorageSql)=>Promise):Promise{ + const holder=held.getStore() + if(!holder?.accepting||transactions.getStore()||journal.getStore())return fail() + const signals=[...new Set([options.signal,holder.options.signal].filter((signal):signal is AbortSignal=>!!signal))] + const limits={deadlineAt:Math.min(options.deadlineAt,holder.options.deadlineAt),signal:signals.length?AbortSignal.any(signals):undefined} + local(holder,limits) + let borrowed=false + const connect=createProbeStorageConnectionConnector(async()=>{ + if(borrowed)fail();borrowed=true + const previous=holder.tail + let finish!:()=>void + const done=new Promise(resolve=>{finish=resolve}) + holder.tail=previous.then(()=>done) + let lent=false,closed=false + try{ + await previous;local(holder,limits) + if(!holder.accepting)fail() + lent=true + return { + async query({text,values,name,query_timeout}){ + local(holder,limits);if(closed)fail() + const result=await holder.connection.query(text,values??[],{signal:limits.signal,timeoutMs:query_timeout,statementName:name}) + if(typeof result.command!=="string")return fail(true) + return {...result,command:result.command} + }, + release(unsafe=false){if(closed)return;closed=true;try{if(unsafe)holder.destroy()}finally{finish()}}, + on:(event,listener)=>holder.connection.on?.(event,listener), + removeListener:(event,listener)=>holder.connection.removeListener?.(event,listener), + } + }finally{if(!lent)finish()} + }) + // Only the existing durable runner owns BEGIN/COMMIT/ROLLBACK. The + // connector checks the actual PostgreSQL COMMIT ACK before releasing this turn. + return journal.run(holder,()=>runPollStorageTransaction({connect,now},limits,authorize,work)) + }, + assertClosedForQuarantine(){ + const holder=held.getStore() + if(!holder||holder.active||!holder.released||journal.getStore())fail() + }, + }) + journalOwners.add(journalOwner) const withLease=async(work:()=>Promise,options:ServerPartitionLeaseOptions):Promise=>{ if(held.getStore()||transactions.getStore())fail() const frozen={...options,serverIds:[...options.serverIds]};checkOptions(frozen) @@ -153,5 +205,5 @@ export function createServerPartitionMaintenanceLease(deps:ServerPartitionMainte holder.active=false;holder.released=true;holder.connection.release(false);return result }catch(error){holder.destroy();throw error}finally{clearTimeout(timer);frozen.signal?.removeEventListener("abort",abort);if(!holder.released)holder.destroy()} } - return {withLease,transaction} + return {withLease,transaction,journalOwner} } diff --git a/backend/src/services/server-poll-archive-accounting.test.ts b/backend/src/services/server-poll-archive-accounting.test.ts index ac581ef8..bea78a73 100644 --- a/backend/src/services/server-poll-archive-accounting.test.ts +++ b/backend/src/services/server-poll-archive-accounting.test.ts @@ -35,7 +35,7 @@ async function compact(n: number, owner: string): Promise { storage_state: "compacted", compact_at: new Date("2026-10-29T02:00:00Z"), admission_archive_bytes: archive.ciphertext_bytes, admission_activity_at: archive.original_activity_at, admission_expires_at: archive.original_expires_at, asserted_ciphertext_bytes: archive.ciphertext_bytes, has_children: false } } } -function fixture(entries: Entry[], retiredCharge?: Parameters[2]) { +function fixture(entries: Entry[], retiredFactory?: Parameters[2]) { const totals = new Map() for (const entry of entries) { for (const scope of ["global", "owner:" + entry.row.admission_owner_id]) { @@ -74,7 +74,7 @@ function fixture(entries: Entry[], retiredCharge?: Parameters ({ factories, charges }), run: () => assertServerPollArchiveAccountingForTests(sql, async () => { factories++; return async row => { charges++; return codec.verifyCharge(row) } - }, retiredCharge) } + }, retiredFactory) } } const refused = (work: Promise) => assert.rejects(work, PollStorageRefused) @@ -91,16 +91,23 @@ test("mixed V1/V2 allocations authenticate actual stored ciphertext and never fa storage_state: "compacted", admission_bytes: 32768, charged_rows: 2 }) second.archive = { ...metadata, ciphertext, archive_proof: archiveProof } Object.assign(second.row, { version: 2, archive_proof: archiveProof }) - const f = fixture([first, second], v2.verifyCharge) + let retiredFactories = 0, retiredCharges = 0 + const f = fixture([first, second], async () => { + retiredFactories++ + return async row => { retiredCharges++; return v2.verifyCharge(row) } + }) await f.run() assert.deepEqual(f.counts(), { factories: 1, charges: 1 }) + assert.deepEqual({ retiredFactories, retiredCharges }, { retiredFactories: 1, retiredCharges: 1 }) second.archive.ciphertext = "A" + ciphertext.slice(1) await refused(f.run()) assert.deepEqual(f.counts(), { factories: 2, charges: 2 }) + assert.deepEqual({ retiredFactories, retiredCharges }, { retiredFactories: 2, retiredCharges: 2 }) second.archive.ciphertext = ciphertext second.row.version = 3 await refused(f.run()) assert.deepEqual(f.counts(), { factories: 3, charges: 3 }) + assert.deepEqual({ retiredFactories, retiredCharges }, { retiredFactories: 2, retiredCharges: 2 }) }) test("empty/global0 and full128 ledgers use complete exact global/owner counters without archive crypto", async () => { @@ -112,10 +119,27 @@ test("empty/global0 and full128 ledgers use complete exact global/owner counters test("compact336 plus full reservations and multiowner usage paginate in128-row bounded metadata batches", async () => { const entries = await Promise.all(Array.from({ length: 336 }, (_, n) => compact(n + 1, "compact-owner-" + n))) + const { createServerPollRetiredArchiveForTests } = await import("./server-poll-retired-archive.js") + const unexpected = async () => { throw new Error("Metadata accounting must not open or write archives") } + const v2 = createServerPollRetiredArchiveForTests({ proof, decrypt: unexpected, encrypt: unexpected, openV1: unexpected, verifyHistory: unexpected }) + for (const index of [127, 128, 335]) { + const entry = entries[index], { archive_proof: _v1Proof, ciphertext, ...previous } = entry.archive! + const metadata = { ...previous, version: 2 as const } + const archiveProof = await proof("server-poll-retired-archive-record-v2", { ...metadata, identity_kind: "mcp", + storage_state: "compacted", admission_bytes: 32768, charged_rows: 2 }) + entry.archive = { ...metadata, ciphertext, archive_proof: archiveProof } + Object.assign(entry.row, { version: 2, archive_proof: archiveProof }) + } entries.push(...Array.from({ length: 8 }, (_, n) => full(1000 + n, "full-owner"))) - const f = fixture(entries); await f.run() + let retiredFactories = 0, retiredCharges = 0 + const f = fixture(entries, async () => { + retiredFactories++ + return async row => { retiredCharges++; return v2.verifyCharge(row) } + }) + await f.run() assert.deepEqual(f.batches, [128, 128, 88]); assert.deepEqual(f.usageBatches, [128, 128, 82]) - assert.deepEqual(f.counts(), { factories: 1, charges: 336 }) + assert.deepEqual(f.counts(), { factories: 1, charges: 333 }) + assert.deepEqual({ retiredFactories, retiredCharges }, { retiredFactories: 1, retiredCharges: 3 }) }) test("forged full/compacted flags, lowering counters, missing global/owner usage and orphan archive cannot create credit", async () => { diff --git a/backend/src/services/server-poll-archive-accounting.ts b/backend/src/services/server-poll-archive-accounting.ts index 0a11bc1c..3c15f26a 100644 --- a/backend/src/services/server-poll-archive-accounting.ts +++ b/backend/src/services/server-poll-archive-accounting.ts @@ -3,7 +3,7 @@ import type { ProbeStorageSql } from "./server-probe-journal-maintenance.js" import { SERVER_POLL_STORAGE_POLICY as policy, PollStorageRefused } from "./server-poll-storage-model.js" import { createServerPollArchiveChargeVerifier, type verifyServerPollArchiveCharge } from "./server-poll-terminal-archive.js" -import type { verifyServerPollRetiredArchiveCharge } from "./server-poll-retired-archive.js" +import type { createServerPollRetiredArchiveChargeVerifier } from "./server-poll-retired-archive.js" const BATCH = 128 const MAX_ENTRIES = Math.floor(policy.globalBytes / policy.admissionBytes) @@ -16,8 +16,8 @@ function integer(value: unknown): number { } interface Total { heavy: number; compact: number; archiveBytes: number; rows: number; bytes: number } const zero = (): Total => ({ heavy: 0, compact: 0, archiveBytes: 0, rows: 0, bytes: 0 }) -const verifyRetiredCharge: typeof verifyServerPollRetiredArchiveCharge = async row => - (await import("./server-poll-retired-archive.js")).verifyServerPollRetiredArchiveCharge(row) +const createRetiredChargeVerifier: typeof createServerPollRetiredArchiveChargeVerifier = async () => + (await import("./server-poll-retired-archive.js")).createServerPollRetiredArchiveChargeVerifier() const QUERY = `SELECT a.receipt_id AS admission_receipt_id,a.identity_kind,a.execution_id AS admission_execution_id, a.connection_id AS admission_connection_id,a.owner_id AS admission_owner_id,a.binding_proof AS admission_binding_proof, a.context_proof AS admission_context_proof,a.storage_state,a.compact_at,a.archive_bytes AS admission_archive_bytes, @@ -38,10 +38,11 @@ const QUERY = `SELECT a.receipt_id AS admission_receipt_id,a.identity_kind,a.exe WHERE ($1::uuid IS NULL OR a.receipt_id>$1::uuid) ORDER BY a.receipt_id LIMIT 128` async function verify(sql: ProbeStorageSql, factory: () => Promise, - retiredCharge: typeof verifyServerPollRetiredArchiveCharge) { + retiredFactory: typeof createServerPollRetiredArchiveChargeVerifier) { const global = zero(), owners = new Map() const seenReceipts = new Set() let cursor: string | null = null, count = 0, charge: typeof verifyServerPollArchiveCharge | undefined + let retiredCharge: Awaited> | undefined // All queries are fixed. Only metadata, hashes and actual lengths cross the // driver boundary; even a full global history never becomes a JS blob. for (;;) { @@ -70,6 +71,7 @@ async function verify(sql: ProbeStorageSql, factory: () => Promise Promise { - try { await verify(sql, createServerPollArchiveChargeVerifier, verifyRetiredCharge) } catch { return fail() } + try { await verify(sql, createServerPollArchiveChargeVerifier, createRetiredChargeVerifier) } catch { return fail() } } export async function assertServerPollArchiveAccountingForTests(sql: ProbeStorageSql, factory: () => Promise, - retiredCharge = verifyRetiredCharge): Promise { - try { await verify(sql, factory, retiredCharge) } catch { return fail() } + retiredFactory = createRetiredChargeVerifier): Promise { + try { await verify(sql, factory, retiredFactory) } catch { return fail() } } diff --git a/backend/src/services/server-poll-durable-session.sql.test.ts b/backend/src/services/server-poll-durable-session.sql.test.ts index a622c2bf..e8dba323 100644 --- a/backend/src/services/server-poll-durable-session.sql.test.ts +++ b/backend/src/services/server-poll-durable-session.sql.test.ts @@ -19,6 +19,11 @@ const {createStorageDurableServerPollSession:createDurableServerPollSession}=awa const {SERVER_PARTITION_WRITER_LOCK}=await import("./server-partition-maintenance-lease.js") const {withPartitionWrite,PartitionWriteError}=await import("./partition-write-transaction.js") const {ensurePartitionFor,PARTITION_SPECS}=await import("../db/partitions.js") +const {compactCompletedServerPollStorage}=await import("./server-poll-terminal-compaction.js") +const {collectExpiredServerPollStorage}=await import("./server-poll-storage-retention.js") +const {tickMcpWorkers}=await import("../modules/mcp/workers.js") +const {ServerPollJournalOutcomeUnknown}=await import("./server-poll-journal.js") +const {Client}=await import("pg") const security=await import("../modules/mcp/security.js") let serverId:number|undefined,requests=0,releasedNative=false,writerPid=0 let releaseNative!:()=>void,firstNative!:()=>void @@ -40,6 +45,20 @@ try{ assert.equal((await pool.query("SELECT count(*)::int AS n FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=ANY($1) AND c.relkind='r'",[["server_poll_executions","server_poll_intents","server_poll_acks","server_poll_receipts","server_poll_quarantines"]])).rows[0].n,5) assert.equal((await pool.query("SELECT count(*)::int AS n FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid WHERE t.tgname='server_partition_writer_guard' AND t.tgenabled='A' AND c.relkind='p'")).rows[0].n,PARTITION_SPECS.length) assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n,0,"Fresh isolated database required; never remove foreign fixtures") + await pool.query(`CREATE TABLE owned_poll_journal_pid(relation text,pid int,xid text,exclusive_writer boolean); + CREATE FUNCTION owned_poll_journal_observe() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN + INSERT INTO public.owned_poll_journal_pid SELECT TG_TABLE_NAME,pg_backend_pid(),pg_current_xact_id()::text, + EXISTS(SELECT 1 FROM pg_catalog.pg_locks WHERE pid=pg_backend_pid() AND locktype='advisory' AND classid=0 + AND objid=8723110 AND objsubid=1 AND mode='ExclusiveLock' AND granted); + RETURN NULL; + END$$;`) + for(const name of ["server_poll_storage_admissions","server_poll_storage_usage","server_poll_executions","server_poll_intents","server_poll_acks","server_poll_receipts","server_poll_quarantines"]){ + await pool.query(`CREATE TRIGGER candidate_application_statement_guard BEFORE INSERT OR UPDATE OR DELETE ON public.${name} + FOR EACH STATEMENT EXECUTE FUNCTION public.server_partition_writer_guard(); + ALTER TABLE public.${name} ENABLE ALWAYS TRIGGER candidate_application_statement_guard; + CREATE TRIGGER owned_poll_journal_observe AFTER INSERT OR UPDATE OR DELETE ON public.${name} + FOR EACH STATEMENT EXECUTE FUNCTION public.owned_poll_journal_observe();`) + } await new Promise((resolve,reject)=>{listener.once("error",reject);listener.listen(0,"127.0.0.1",()=>{listener.off("error",reject);resolve()})}) const port=(listener.address()as{port:number}).port serverId=Number((await pool.query("INSERT INTO servers(name,host,port,enabled,username,password,use_ssl,verify_ssl) VALUES('poll-owned-before','127.0.0.1',$1,true,'SYNTHETIC','SYNTHETIC',false,true) RETURNING id",[port])).rows[0].id) @@ -78,6 +97,22 @@ try{ assert(plan.maintenance.some(effect=>effect.kind==="partition.drop"&&effect.name===expired)) const pending=session.execute(plan);void pending.catch(()=>{}) await Promise.race([started,pending.then(()=>assert.fail("Native request must occur before completion"))]) + const {rows:[holder]}=await pool.query(`SELECT pid FROM pg_catalog.pg_locks WHERE locktype='advisory' AND classid=0 + AND objid=$1::oid AND objsubid=1 AND mode='ExclusiveLock' AND granted`,[SERVER_PARTITION_WRITER_LOCK]) + assert(Number.isInteger(holder?.pid)) + const prefix=(await pool.query("SELECT relation,pid,xid,exclusive_writer FROM owned_poll_journal_pid")).rows + assert(prefix.some(row=>row.relation==="server_poll_executions"));assert(prefix.some(row=>row.relation==="server_poll_intents")) + assert(prefix.every(row=>row.pid===holder.pid&&row.exclusive_writer===true)) + const anchor=prefix.find(row=>row.relation==="server_poll_executions")! + assert(prefix.some(row=>row.relation==="server_poll_storage_admissions"&&row.xid!==anchor.xid),"Reservation and consume/anchor are independently committed") + assert(prefix.some(row=>row.relation==="server_poll_storage_admissions"&&row.xid===anchor.xid),"Consume and anchor share one transaction") + const workerErrors:unknown[]=[] + await tickMcpWorkers().catch(error=>workerErrors.push(error)) + assert.deepEqual(workerErrors,[],"An actual worker tick must defer maintenance without an error while native POLL owns the exclusive lock") + assert.equal((await compactCompletedServerPollStorage()).compacted,0) + assert.equal((await collectExpiredServerPollStorage()).collected,0) + assert.equal((await pool.query("SELECT count(*)::int AS n FROM pg_locks WHERE locktype='advisory' AND pid=$1 AND objid=$2::oid AND mode='ExclusiveLock' AND granted",[holder.pid,SERVER_PARTITION_WRITER_LOCK])).rows[0].n,1) + assert.equal((await pool.query("SELECT pg_try_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0)) AS free")).rows[0].free,true) // Actual ordinary writer arrives while FULL poll holds the exclusive lease. // Inspect lock evidence before releasing HTTP; no target relation is touched. const controller=new AbortController() @@ -88,6 +123,8 @@ try{ assert.equal((await pool.query("SELECT count(*)::int AS n FROM pg_locks l JOIN pg_class c ON c.oid=l.relation WHERE l.pid=$1 AND l.locktype='relation' AND (c.relname='traffic_samples' OR c.relname LIKE 'traffic_samples_w_%')",[writerPid])).rows[0].n,0) }finally{controller.abort();await assert.rejects(blocked,(error:unknown)=>error instanceof PartitionWriteError&&!error.outcomeUnknown);releaseNative()} const receipt=await pending + assert.equal((await compactCompletedServerPollStorage()).compacted,0) + assert.equal((await collectExpiredServerPollStorage()).collected,0) assert.equal(receipt.complete,true);assert.equal(receipt.maintenanceComplete,true);assert.equal(receipt.nativeReads,4) assert.equal(receipt.completedPublications,plan.maintenance.length+4) assert.deepEqual([...paths].sort(),["/rest/interface","/rest/ip/address","/rest/system/identity","/rest/system/resource"].sort()) @@ -98,6 +135,11 @@ try{ assert.equal((await pool.query("SELECT count(*)::int AS n FROM server_poll_intents WHERE receipt_id=$1",[receiptId])).rows[0].n,receipt.completedPublications+4) assert.equal((await pool.query("SELECT count(*)::int AS n FROM server_poll_acks WHERE receipt_id=$1",[receiptId])).rows[0].n,receipt.completedPublications+4) assert.equal((await pool.query("SELECT count(*)::int AS n FROM server_poll_receipts WHERE receipt_id=$1",[receiptId])).rows[0].n,1) + const fullJournal=(await pool.query("SELECT relation,pid,xid,exclusive_writer FROM owned_poll_journal_pid")).rows + assert(fullJournal.every(row=>row.pid===holder.pid&&row.exclusive_writer===true)) + assert(fullJournal.some(row=>row.relation==="server_poll_acks")&&fullJournal.some(row=>row.relation==="server_poll_receipts")) + assert(new Set(fullJournal.filter(row=>row.relation==="server_poll_intents").map(row=>row.xid)).size===receipt.completedPublications+4) + assert.deepEqual(await session.readExisting(plan),receipt) // Measure a COMPLETE real journal, including ciphertext, proofs, timestamps, // admission and every stage. This is sizing evidence, not an archive format // or permission to delete/compact rows. Never print the captured payload. @@ -118,6 +160,39 @@ try{ chargedBytes:Number(stored.admission.used_bytes),fullJsonBytes:fullBytes.length,gzipBytes:compressed.length,gzipRoundTripExact:true, sampleFits128KiB:compressed.length<=131072,identityKind:stored.admission.identity_kind,compactionPerformed:false, archiveAuthenticationAccepted:false,historicalVerifierAccepted:false,productionCapacityAccepted:false})) + const lostRequest={serverId,receiptId:randomUUID()},lostIdentity={...identity,executionId:randomUUID(),requestId:randomUUID()} + const originalAuthority=sessionInput.authorize + const lostSession=createDurableServerPollSession({request:lostRequest,identity:lostIdentity,authorize:async(request,ids,signal)=>{ + assert.deepEqual(request,lostRequest) + return originalAuthority(sessionRequest,ids,signal) + }}) + const lostPlan=await lostSession.prepare(),originalQuery=Client.prototype.query + type IdentifiedClient=InstanceType&{processID?:number} + let reservedBackend:IdentifiedClient|undefined,acksLost=0 + try{ + Client.prototype.query=function(this:InstanceType,...args:unknown[]){ + const request=args[0] as {text?:string;values?:unknown[]} + if(request?.text?.startsWith("INSERT INTO server_poll_storage_admissions")&&request.values?.[0]===lostRequest.receiptId)reservedBackend=this + if(this===reservedBackend&&request?.text==="COMMIT"&&acksLost===0){ + return Promise.resolve(Reflect.apply(originalQuery,this,args)).then((result:{command:string})=>{ + assert.equal(result.command,"COMMIT");acksLost++;throw Error("owned real reservation COMMIT ACK lost") + }) + } + return Reflect.apply(originalQuery,this,args) + } as typeof Client.prototype.query + await assert.rejects(lostSession.execute(lostPlan),error=>error instanceof ServerPollJournalOutcomeUnknown&&error.automaticReplay===false&&!error.requiresDurableQuarantine) + }finally{Client.prototype.query=originalQuery} + assert.equal(acksLost,1);assert.equal(requests,4) + const {rows:[lostState]}=await pool.query(`SELECT + (SELECT count(*)::int FROM server_poll_storage_admissions WHERE receipt_id=$1) AS charged, + (SELECT count(*)::int FROM server_poll_executions WHERE receipt_id=$1) AS anchored, + (SELECT count(*)::int FROM server_poll_quarantines WHERE receipt_id=$1) AS quarantined`,[lostRequest.receiptId]) + assert.deepEqual(lostState,{charged:1,anchored:0,quarantined:1}) + const {rows:[quarantine]}=await pool.query("SELECT pid,exclusive_writer FROM owned_poll_journal_pid WHERE relation='server_poll_quarantines'") + assert(Number.isInteger(reservedBackend?.processID)) + assert.notEqual(quarantine.pid,reservedBackend?.processID);assert.equal(quarantine.exclusive_writer,false) + assert.equal(await lostSession.readExisting(lostPlan),undefined) + console.log("Guarded durable POLL journal acceptance:",JSON.stringify({sameHolder:true,independentCommits:true,concurrentMaintenanceNoEarlyChanges:true,workerErrors:workerErrors.length,readExisting:true,realLostCommitAckNoReplay:true,independentQuarantine:true})) sessionRequest.serverId++ identity.ownerId="mutated-outer-input";identity.expiresAt=0 sessionInput.authorize=async()=>{throw Error("Outer caller replaced authority provider")} @@ -125,7 +200,7 @@ try{ await assert.rejects(session.execute({...plan,bindingProof:"0".repeat(64)}));assert.equal(requests,4) await withPartitionWrite(async owner=>{await owner.client.query("INSERT INTO traffic_samples(server_id,interface_name,sampled_at,rx_bytes) VALUES($1,'after-full-poll',now(),1)",[serverId])}) revoked=true;await assert.rejects(session.execute(plan));assert.equal(requests,4) - console.log("Full durable poll actual SQL/HTTP acceptance:",JSON.stringify({fourNativeGets:requests,maintenanceEffects:plan.maintenance.length,parents:PARTITION_SPECS.length,completedPublications:receipt.completedPublications,sameBackendLeaseAndTransactions:true,ordinaryWriterBlockedBeforeRelationSql:true,realJournal:true,repeatedApplyNoNewNative:true,outerInputMutationCannotReplaceCapturedIdentityOrAuthority:true,changedPlanBindingRefused:true,lateRevocationRefused:true,actualHttpAuthenticationMiddleware:false,productionRegistration:false,externalNetwork:false,authorityReads})) + console.log("Full durable poll actual SQL/HTTP acceptance:",JSON.stringify({fourNativeGets:requests,maintenanceEffects:plan.maintenance.length,parents:PARTITION_SPECS.length,completedPublications:receipt.completedPublications,sameBackendLeaseAndTransactions:true,guardedSameHolderJournal:true,separateDurableJournalCommits:true,independentQuarantineAfterRealLostCommitAck:true,ordinaryWriterBlockedBeforeRelationSql:true,realJournal:true,repeatedApplyNoNewNative:true,outerInputMutationCannotReplaceCapturedIdentityOrAuthority:true,changedPlanBindingRefused:true,lateRevocationRefused:true,actualHttpAuthenticationMiddleware:false,productionRegistration:false,externalNetwork:false,authorityReads})) }finally{ if(!releasedNative)releaseNative() listener.closeAllConnections();if(listener.listening)await new Promise(resolve=>listener.close(()=>resolve())) diff --git a/backend/src/services/server-poll-durable-session.ts b/backend/src/services/server-poll-durable-session.ts index 1b3e596a..dfe96b9a 100644 --- a/backend/src/services/server-poll-durable-session.ts +++ b/backend/src/services/server-poll-durable-session.ts @@ -5,7 +5,7 @@ import { createServerPartitionMaintenanceLease } from "./server-partition-mainte import { createSqlServerPollJournal, parseServerPollJournalIdentity, ServerPollJournalConflict, type ServerPollJournalIdentity } from "./server-poll-journal.js" import { pollProblem } from "./server-poll-execution-model.js" import type { ServerPollDependencies, ServerPollPlan, ServerPollRequest } from "./server-poll-execution.js" -import { createServerPollStorageJournal } from "./server-poll-storage-journal.js" +import { createHolderBoundServerPollStorageJournal, createServerPollStorageJournal } from "./server-poll-storage-journal.js" import { createServerPollBoundaryCoordinator } from "./server-poll-boundary.js" export interface DurableServerPollSessionInput { @@ -64,7 +64,7 @@ function durableSession(input:DurableServerPollSessionInput,storageBound:boolean // accidental unbound execution impossible; they never invent an ACK/claim. const unavailable=async():Promise=>{throw new ServerPollJournalConflict()} const prepare=async(signal?:AbortSignal)=>build({claim:unavailable,intent:unavailable,acknowledge:unavailable,complete:unavailable}).prepare(request,signal) - const journalFor=async(raw:ServerPollPlan,signal?:AbortSignal)=>{ + const journalFor=async(raw:ServerPollPlan,mode:"execute"|"read",signal?:AbortSignal)=>{ const plan=structuredClone(raw) if(plan.request.serverId!==request.serverId||plan.request.receiptId!==request.receiptId)pollProblem("STATE_CONFLICT","План не принадлежит durable poll session") const deadlineAt=Math.min(identity.expiresAt,Date.now()+SERVER_POLL_WALL_BUDGET_MS) @@ -74,14 +74,17 @@ function durableSession(input:DurableServerPollSessionInput,storageBound:boolean if(grant.authorityProof!==context.plan.authorityProof)pollProblem("STATE_CONFLICT","Полномочия durable poll изменились") } const context={identity,plan} - const storage=storageBound?await createServerPollStorageJournal(context,{assertAuthority,deadlineAt,signal}):undefined + const storageOptions={assertAuthority,deadlineAt,signal} + const storage=storageBound?await (mode==="execute" + ?createHolderBoundServerPollStorageJournal(context,storageOptions,lease.journalOwner) + :createServerPollStorageJournal(context,storageOptions)):undefined const journal=createSqlServerPollJournal(context,{ executionProof:proof,deadlineAt,assertAuthority,storage, }) return {journal,plan} } - const execute=async(raw:ServerPollPlan,signal?:AbortSignal)=>{const {journal,plan}=await journalFor(raw,signal);return build(journal).execute(plan,signal)} - const readExisting=async(raw:ServerPollPlan,signal?:AbortSignal)=>{const {journal}=await journalFor(raw,signal);return journal.readExisting()} + const execute=async(raw:ServerPollPlan,signal?:AbortSignal)=>{const {journal,plan}=await journalFor(raw,"execute",signal);return build(journal).execute(plan,signal)} + const readExisting=async(raw:ServerPollPlan,signal?:AbortSignal)=>{const {journal}=await journalFor(raw,"read",signal);return journal.readExisting()} return {prepare,execute,readExisting} } /** Historical private assembly for the enrolled 0038 fixture; public adapters diff --git a/backend/src/services/server-poll-operation-retirement.owned.test.ts b/backend/src/services/server-poll-operation-retirement.owned.test.ts index f82aeb36..bf46c47b 100644 --- a/backend/src/services/server-poll-operation-retirement.owned.test.ts +++ b/backend/src/services/server-poll-operation-retirement.owned.test.ts @@ -3,6 +3,7 @@ * production crypto and all database guards enabled. */ import assert from "node:assert/strict" import { randomUUID } from "node:crypto" +import { setTimeout as delay } from "node:timers/promises" import { assertOwnedBandwidthSql } from "./bandwidth-admission-process.sql-helper.js" assertOwnedBandwidthSql() @@ -14,16 +15,55 @@ const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp") const { invokeMcpTool } = await import("../modules/mcp/runtime.js") const { encryptSecret,privateSecretFingerprint,fingerprint,redact } = await import("../modules/mcp/security.js") const { encodeServerPollJournalAnchor } = await import("./server-poll-journal-anchor.js") -const { compactCompletedServerPollStorage,SERVER_POLL_ARCHIVE_READ_SQL } = await import("./server-poll-terminal-compaction.js") +const { compactCompletedServerPollStorage,compactCompletedServerPollStorageForTests,SERVER_POLL_ARCHIVE_READ_SQL } = await import("./server-poll-terminal-compaction.js") const { openServerPollTerminalArchive } = await import("./server-poll-terminal-archive.js") const { retireCompletedServerPollOperation } = await import("./server-poll-operation-retirement.js") -const { collectExpiredServerPollStorage } = await import("./server-poll-storage-retention.js") -const { PollStorageUnknown } = await import("./server-poll-storage.js") +const { PARTITION_WRITER_ADVISORY_KEY } = await import("./partition-write-transaction.js") +const { collectExpiredServerPollStorage,collectExpiredServerPollStorageForTests } = await import("./server-poll-storage-retention.js") +const { connectProbeStorage } = await import("./server-probe-storage-connection.js") +const { PollStorageRefused,PollStorageUnknown } = await import("./server-poll-storage.js") const { tickMcpWorkers } = await import("../modules/mcp/workers.js") const { Client } = await import("pg") const proof=(domain:string,value:unknown)=>privateSecretFingerprint(fingerprint(value),domain) const encrypt=(value:unknown)=>encryptSecret(JSON.stringify(value)) +function loseMaintenanceCommit(at:number,afterFirst?:()=>Promise){ + const transactions:{commands:string[];release?:boolean}[]=[] + let commits=0 + return {transactions,deps:{now:Date.now,connect:async(options:Parameters[0])=>{ + const physical=await connectProbeStorage(options),trace:{commands:string[];release?:boolean}={commands:[]} + transactions.push(trace) + return {async query(...args:Parameters){ + const [text]=args + trace.commands.push(text) + const result=await physical.query(...args) + if(text==="COMMIT"){ + commits++ + if(commits===1)await afterFirst?.() + if(commits===at)throw Error("owned POLL maintenance COMMIT ACK lost") + } + return result + },release(unsafe?:boolean){trace.release=unsafe;physical.release(unsafe)}} + }}} +} +function assertMaintenanceOrder(fault:ReturnType,phaseCount:number){ + assert.deepEqual(fault.transactions.map(tx=>tx.release),[true],"One physical owner, destroyed once on unknown COMMIT") + const [{commands}]=fault.transactions + assert.equal(commands.includes("ROLLBACK"),false) + assert.equal(commands.some(text=>text.includes("pg_advisory_unlock_shared")),false) + assert.equal(commands.filter(text=>text==="BEGIN").length,phaseCount) + assert.equal(commands.filter(text=>text==="COMMIT").length,phaseCount) + let previousCommit=-1 + for(let phase=0;phase0&&writernew Date(base+offset).toISOString() const ownerId=randomUUID(),executionId=randomUUID(),receiptId=randomUUID(),planId=randomUUID() @@ -100,11 +140,68 @@ try{ await pool.query("UPDATE mcp_settings SET enabled=true,requests_retention_days=365 WHERE id=1") const {rows:[server]}=await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned historical poll','127.0.0.1',false) RETURNING id") const retained=await historical(181,Number(server.id)), expired=await historical(366,Number(server.id)) + for(const table of ["server_poll_storage_admissions","server_poll_storage_usage","server_poll_archives","server_poll_retirement_claims"]){ + await pool.query(`CREATE TRIGGER owned_poll_worker_fence BEFORE INSERT OR UPDATE OR DELETE ON public.${table} + FOR EACH STATEMENT EXECUTE FUNCTION public.server_partition_writer_guard(); + ALTER TABLE public.${table} ENABLE ALWAYS TRIGGER owned_poll_worker_fence`) + } for(const fixture of [expired,retained]){ - assert.equal((await compactCompletedServerPollStorage()).compacted,1) + if(fixture===expired){ + const rival=await pool.connect() + let waiting:Promise|undefined + try{ + const {rows:[owner]}=await rival.query("SELECT pg_backend_pid() AS pid") + const fault=loseMaintenanceCommit(2,async()=>{ + waiting=rival.query("SELECT pg_advisory_lock($1::bigint)",[PARTITION_WRITER_ADVISORY_KEY]);void waiting.catch(()=>{}) + let queued=false + for(const end=Date.now()+2000;!queued&&Date.now()error instanceof PollStorageUnknown&&!error.automaticReplay) + assertMaintenanceOrder(fault,2) + await waiting + assert.equal((await rival.query("SELECT pg_advisory_unlock($1::bigint) AS released",[PARTITION_WRITER_ADVISORY_KEY])).rows[0].released,true) + }finally{rival.release(true)} + }else assert.equal((await compactCompletedServerPollStorage()).compacted,1) const {rows:[v1]}=await pool.query(SERVER_POLL_ARCHIVE_READ_SQL,[fixture.receiptId]) await openServerPollTerminalArchive(v1,Date.now()) const before=(await pool.query("SELECT gc_not_before,authority_expires_at,retention_days,original_activity_at FROM server_poll_storage_admissions WHERE receipt_id=$1",[fixture.receiptId])).rows[0] + if(fixture===expired){ + const exclusive=await pool.connect() + let blocked:ReturnType|undefined + try{ + await exclusive.query("SELECT pg_advisory_lock($1::bigint)",[PARTITION_WRITER_ADVISORY_KEY]) + await exclusive.query("BEGIN") + blocked=retireCompletedServerPollOperation({receiptId:fixture.receiptId,oldArchiveProof:v1.archive_proof}) + void blocked.catch(()=>{}) + let waiting=false + for(const deadline=Date.now()+3000;!waiting&&Date.now(){})}finally{ + try{ + await exclusive.query("ROLLBACK") + assert.equal((await exclusive.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked",[PARTITION_WRITER_ADVISORY_KEY])).rows[0].unlocked,true) + }finally{exclusive.release(true)} + } + } + } await assert.rejects(retireCompletedServerPollOperation({receiptId:fixture.receiptId,oldArchiveProof:"0".repeat(64)})) const hold=randomUUID() await pool.query("INSERT INTO mcp_drift(id,operation_id,expected,observed) VALUES($1,$2,'{}','{}')",[hold,fixture.executionId]) @@ -130,8 +227,15 @@ try{ await assert.rejects(invokeMcpTool(foreign,"mm_global_operation_reconcile",{operationId:fixture.executionId}), (error:unknown)=>(error as {code?:string}).code==="NOT_FOUND") } - assert.equal((await collectExpiredServerPollStorage()).collected,1) + const expiredV1=await historical(367,Number(server.id)) + assert.equal((await compactCompletedServerPollStorage()).compacted,1) + assert.equal((await pool.query(SERVER_POLL_ARCHIVE_READ_SQL,[expiredV1.receiptId])).rows[0].version,1) + const gcFault=loseMaintenanceCommit(1) + await assert.rejects(collectExpiredServerPollStorageForTests(gcFault.deps),error=>error instanceof PollStorageUnknown&&!error.automaticReplay) + assertMaintenanceOrder(gcFault,1) assert.equal((await collectExpiredServerPollStorage()).collected,0) + assert.equal((await pool.query("SELECT 1 FROM mcp_operation_replay_tombstones WHERE operation_id=$1",[expired.executionId])).rows.length,1) + assert.equal((await pool.query("SELECT 1 FROM mcp_operations WHERE id=$1",[expiredV1.executionId])).rows.length,1) const {rows}=await pool.query("SELECT r.receipt_id,r.ciphertext_bytes,u.reserved_bytes,u.reserved_rows FROM server_poll_archives r CROSS JOIN server_poll_storage_usage u WHERE u.scope='global'") assert.equal(rows.length,1);assert.equal(rows[0].receipt_id,retained.receiptId) assert.equal(Number(rows[0].reserved_bytes),32768+rows[0].ciphertext_bytes);assert.equal(Number(rows[0].reserved_rows),2) @@ -146,10 +250,15 @@ try{ await pool.query("INSERT INTO mcp_drift(id,operation_id,expected,observed) VALUES($1,$2,'{}','{}')",[randomUUID(),held.executionId]) const {rows:[spentV1]}=await pool.query(SERVER_POLL_ARCHIVE_READ_SQL,[spent.receiptId]) const originalQuery=Client.prototype.query + const admissionOrder:string[]=[] let commits=0 try{ Client.prototype.query=function(this:InstanceType,...args:unknown[]){ - if((args[0] as {text?:string})?.text==="COMMIT" && ++commits===2) { + const text=(args[0] as {text?:string})?.text + if(text==="SELECT pg_advisory_xact_lock_shared($1::bigint)")admissionOrder.push("common") + if(text==="SELECT pg_catalog.set_config('lock_timeout',$1,true)")admissionOrder.push("restore timeout") + if(text==="SELECT pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0))")admissionOrder.push("storage") + if(text==="COMMIT" && ++commits===2) { // No COMMIT ACK can reach the owner. Closing the real checked-out // backend rolls back uncommitted work, leaving the earlier claim spent. return Promise.reject(Error("owned poll retirement COMMIT transport loss")) @@ -160,6 +269,7 @@ try{ (error:unknown)=>error instanceof PollStorageUnknown && error.automaticReplay===false) }finally{Client.prototype.query=originalQuery} assert.equal(commits,2) + assert.deepEqual(admissionOrder,["common","restore timeout","storage","common","restore timeout","storage"]) assert.equal((await pool.query(SERVER_POLL_ARCHIVE_READ_SQL,[spent.receiptId])).rows[0].version,1) assert.equal((await pool.query("SELECT 1 FROM server_poll_retirement_claims WHERE receipt_id=$1",[spent.receiptId])).rows.length,1) const workerProjection=async()=>(await pool.query(`SELECT a.receipt_id,r.version,r.archive_proof,a.archive_bytes, @@ -179,5 +289,14 @@ try{ await tickMcpWorkers() assert.deepEqual(await workerProjection(),afterWorker) assert.equal((await pool.query("SELECT server_poll_storage_accounting_valid() AS valid")).rows[0].valid,true) - console.log("Owned PostgreSQL: authenticated V1/V2 transition, retained history, once-only refund, and actual worker skips held/spent rows") + + const lostClaim=await historical(187,Number(server.id)),claimFault=loseMaintenanceCommit(1) + await assert.rejects(compactCompletedServerPollStorageForTests(claimFault.deps),error=>error instanceof PollStorageUnknown&&!error.automaticReplay) + assertMaintenanceOrder(claimFault,1) + const {rows:[claimState]}=await pool.query(`SELECT storage_state,compaction_claim IS NOT NULL AND compaction_claim_xid IS NOT NULL AS spent, + NOT EXISTS(SELECT 1 FROM server_poll_archives WHERE receipt_id=$1) AS unarchived, + server_poll_storage_accounting_valid() AS accounting FROM server_poll_storage_admissions WHERE receipt_id=$1`,[lostClaim.receiptId]) + assert.deepEqual(claimState,{storage_state:"full",spent:true,unarchived:true,accounting:true}) + assert.deepEqual(await compactCompletedServerPollStorage(),{scanned:0,compacted:0,skipped:0,automaticReplay:false}) + console.log("Owned PostgreSQL: guarded compaction and mixed V1/V2 GC, physical claim/publish/refund ACK losses, retained history, and actual worker skips held/spent rows") }finally{await closePool()} diff --git a/backend/src/services/server-poll-operation-retirement.ts b/backend/src/services/server-poll-operation-retirement.ts index 9f2f37de..75253e4f 100644 --- a/backend/src/services/server-poll-operation-retirement.ts +++ b/backend/src/services/server-poll-operation-retirement.ts @@ -10,6 +10,7 @@ import { PollStorageRefused, runPollStorageTransaction } from "./server-poll-sto import type { ProbeStorageSql } from "./server-probe-journal-maintenance.js" import { connectProbeStorage } from "./server-probe-storage-connection.js" import { sharedDatabaseGate } from "./shared-database-gate.js" +import { admitPartitionWriter } from "./partition-write-transaction.js" const request = z.object({ receiptId: z.string().uuid(), oldArchiveProof: z.string().regex(/^[a-f0-9]{64}$/) }).strict() const requireThat: (value: unknown) => asserts value = value => { @@ -50,8 +51,16 @@ const CANDIDATES = `SELECT a.receipt_id,r.archive_proof,o.finished_at::text AS f ORDER BY o.finished_at,a.receipt_id LIMIT 8` const deps = { connect:connectProbeStorage,now:Date.now } -const transaction = (deadlineAt:number, work:(sql:ProbeStorageSql)=>Promise) => - runPollStorageTransaction(deps,{deadlineAt},async()=>{},work) +async function admitRetirementWriter(sql:ProbeStorageSql,deadlineAt:number) { + const settings=(await sql.query("SELECT pg_catalog.current_setting('lock_timeout') AS lock_timeout")).rows + requireThat(settings.length===1 && typeof settings[0].lock_timeout==="string") + // The common fence precedes the POLL storage lock. Its bounded LOCAL + // lock_timeout must not change how later POLL locks observe contention. + await admitPartitionWriter(sql,{deadlineAt:Math.min(deadlineAt,Date.now()+5000)}) + await sql.query("SELECT pg_catalog.set_config('lock_timeout',$1,true)",[settings[0].lock_timeout]) +} +const transaction = (deadlineAt:number,mode:"scan"|"write",work:(sql:ProbeStorageSql)=>Promise) => + runPollStorageTransaction(deps,{deadlineAt},async()=>{},work,mode==="write"?sql=>admitRetirementWriter(sql,deadlineAt):undefined) type Target = z.infer async function prepare(sql: ProbeStorageSql,{receiptId,oldArchiveProof}:Target) { @@ -84,7 +93,7 @@ async function prepare(sql: ProbeStorageSql,{receiptId,oldArchiveProof}:Target) async function retire(target:Target,deadlineAt:number) { const {receiptId,oldArchiveProof}=target const token=randomUUID() - await transaction(deadlineAt,async sql=>{ + await transaction(deadlineAt,"write",async sql=>{ // Check the live hold predicates, all locks and both archive proofs before // spending the irreversible one-shot claim. await prepare(sql,target) @@ -94,7 +103,7 @@ async function retire(target:Target,deadlineAt:number) { }) // No destructive transaction after an unacknowledged claim. A claim is // never cleared, even when a later checkout/hold/COMMIT is inconclusive. - return transaction(deadlineAt,async sql=>{ + return transaction(deadlineAt,"write",async sql=>{ const claimed=await sql.query(`SELECT receipt_id FROM public.server_poll_retirement_claims WHERE receipt_id=$1 AND old_archive_proof=$2 AND claim_token=$3 AND claim_xid<>pg_current_xact_id()`, [receiptId,oldArchiveProof,token]) @@ -141,7 +150,7 @@ let cursor:{finishedAt:string;receiptId:string}|undefined export function retireCompletedServerPollOperations() { return sharedDatabaseGate.request(async()=>{ const deadlineAt=Date.now()+20000 - const rows=await transaction(deadlineAt,async sql=>(await sql.query(CANDIDATES, + const rows=await transaction(deadlineAt,"scan",async sql=>(await sql.query(CANDIDATES, [cursor?.finishedAt??null,cursor?.receiptId??null])).rows) requireThat(rows.length<=8) let retired=0,scanned=0 diff --git a/backend/src/services/server-poll-production.ts b/backend/src/services/server-poll-production.ts index 168bcf40..6861e383 100644 --- a/backend/src/services/server-poll-production.ts +++ b/backend/src/services/server-poll-production.ts @@ -11,8 +11,10 @@ import { createServerPollExecution,type ServerPollDependencies,type ServerPollGr import { ServerPollProblem,pollProblem,validateServerPollContext,planServerPollMaintenance,type ServerPollContext,type PollRow,type ServerPollAck } from "./server-poll-execution-model.js" export interface ServerPollSqlConnection { - query(text:string,values:unknown[],options:{signal?:AbortSignal;timeoutMs:number}):Promise<{rows:Record[];rowCount?:number|null}> + query(text:string,values:unknown[],options:{signal?:AbortSignal;timeoutMs:number;statementName?:string}):Promise<{rows:Record[];rowCount?:number|null;command?:string}> release(unsafe?:boolean):void + on?(event:"error",listener:(error:Error)=>void):unknown + removeListener?(event:"error",listener:(error:Error)=>void):unknown } export interface ServerPollProductionIo { connect(options:{signal?:AbortSignal;deadlineAt:number}):Promise @@ -146,8 +148,8 @@ export function createServerPollProductionIo():ServerPollProductionIo{ queryOptions.signal?.throwIfAborted();if(released)pollProblem("STATE_UNAVAILABLE","SQL connection poll закрыт") const cancel=()=>{unsafe=true;release(true)};queryOptions.signal?.addEventListener("abort",cancel,{once:true}) if(queryOptions.signal?.aborted){cancel();pollProblem("ABORTED","SQL server poll отменён до dispatch")} - try{const request:QueryConfig&{query_timeout:number}={text,values,query_timeout:queryOptions.timeoutMs};return await client.query>(request)}catch(error){unsafe=true;throw error}finally{queryOptions.signal?.removeEventListener("abort",cancel)} - },release} + try{const request:QueryConfig&{query_timeout:number}={text,values,name:queryOptions.statementName,query_timeout:queryOptions.timeoutMs};return await client.query>(request)}catch(error){unsafe=true;throw error}finally{queryOptions.signal?.removeEventListener("abort",cancel)} + },release,on:(event,listener)=>client.on(event,listener),removeListener:(event,listener)=>client.removeListener(event,listener)} }, applicationLock:async work=>(await import("./config-mutation-lock.js")).withApplicationMutationLock(work), serverLocks:async(ids,work)=>(await import("./config-mutation-lock.js")).withConfigMutationLock(ids,work), diff --git a/backend/src/services/server-poll-retired-archive.test.ts b/backend/src/services/server-poll-retired-archive.test.ts index ffb98f4b..b58124ff 100644 --- a/backend/src/services/server-poll-retired-archive.test.ts +++ b/backend/src/services/server-poll-retired-archive.test.ts @@ -1,8 +1,11 @@ import assert from "node:assert/strict" import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto" +import { mkdtemp, rmdir, unlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { test } from "node:test" import { fingerprint } from "../modules/mcp/security.js" -import { createServerPollRetiredArchiveForTests } from "./server-poll-retired-archive.js" +import { createServerPollRetiredArchiveChargeVerifier, createServerPollRetiredArchiveForTests } from "./server-poll-retired-archive.js" import { createServerPollTerminalArchiveForTests, ServerPollArchiveRefused } from "./server-poll-terminal-archive.js" import type { VerifiedServerPollHistory } from "./server-poll-history-verification.js" @@ -20,6 +23,37 @@ const gcNotBefore = "2026-11-29T03:20:00.123460Z" const now = Date.parse("2026-11-30T00:00:00Z") const key = randomBytes(32), mac = randomBytes(32) const proof = async (purpose: string, value: unknown) => createHmac("sha256", mac).update(purpose).update(fingerprint(value)).digest("hex") + +test("retired archive accounting derives the existing key anew for each scan and cannot bootstrap it", async () => { + const directory = await mkdtemp(join(tmpdir(), "mcp-retired-archive-key-")) + const filename = join(directory, "encryption-key"), previous = process.env.MCP_STATE_DIR + try { + process.env.MCP_STATE_DIR = directory + await assert.rejects(createServerPollRetiredArchiveChargeVerifier(), { code: "STATE_UNAVAILABLE" }) + await writeFile(filename, key, { flag: "wx" }) + const verifier = await createServerPollRetiredArchiveChargeVerifier() + const metadata = { + version: 2, receipt_id: receiptId, execution_id: executionId, connection_id: connectionId, owner_id: ownerId, + binding_proof: "a".repeat(64), context_proof: "b".repeat(64), terminal_digest: "c".repeat(64), + original_activity_at: terminalAt, original_expires_at: expiredAt, retention_days: 30, safety_ms: 60000, + source_digest: "d".repeat(64), source_sha256: "e".repeat(64), decoded_bytes: 10, compressed_bytes: 20, + ciphertext_bytes: 64, ciphertext_sha256: "f".repeat(64), + } + const purposeKey = createHmac("sha256", key).update("MCP private fingerprint:server-poll-retired-archive-record-v2").digest() + const archiveProof = createHmac("sha256", purposeKey).update(fingerprint({ + ...metadata, identity_kind: "mcp", storage_state: "compacted", admission_bytes: 32768, charged_rows: 2, + })).digest("hex") + const row = { ...metadata, archive_proof: archiveProof } + assert.equal((await verifier(row)).bytes, 32832) + await assert.rejects(verifier({ ...row, ciphertext_sha256: "0".repeat(64) }), ServerPollArchiveRefused) + await writeFile(filename, randomBytes(32)) + await assert.rejects((await createServerPollRetiredArchiveChargeVerifier())(row), ServerPollArchiveRefused) + } finally { + if (previous === undefined) delete process.env.MCP_STATE_DIR; else process.env.MCP_STATE_DIR = previous + await unlink(filename).catch(error => { if (error.code !== "ENOENT") throw error }) + await rmdir(directory) + } +}) const encrypt = async (plain: Buffer) => { const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv) const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]) diff --git a/backend/src/services/server-poll-retired-archive.ts b/backend/src/services/server-poll-retired-archive.ts index 05140231..9fe0dec6 100644 --- a/backend/src/services/server-poll-retired-archive.ts +++ b/backend/src/services/server-poll-retired-archive.ts @@ -5,7 +5,7 @@ import { gzipSync, gunzipSync } from "node:zlib" import { z } from "zod" import { canonicalPollArchiveTimestamp, openServerPollTerminalArchive, ServerPollArchiveRefused } from "./server-poll-terminal-archive.js" import { verifyServerPollHistory } from "./server-poll-history-verification.js" -import { decryptBinary, encryptBinary, fingerprint, privateSecretFingerprint } from "../modules/mcp/security.js" +import { decryptBinary, encryptBinary, fingerprint, privateFingerprinterExisting, privateSecretFingerprint } from "../modules/mcp/security.js" const MAX_PAYLOAD_BYTES = 24576 const MAX_COMPRESSED_BYTES = MAX_PAYLOAD_BYTES @@ -156,13 +156,21 @@ function codec(deps: Dependencies) { return Object.freeze({ project, verifyCharge, open }) } -const production = codec({ +const productionDependencies: Dependencies = { openV1: openServerPollTerminalArchive, verifyHistory: verifyServerPollHistory, encrypt: encryptBinary, decrypt: decryptBinary, proof: (purpose, value) => privateSecretFingerprint(fingerprint(value), purpose), -}) +} +const production = codec(productionDependencies) export const projectServerPollRetiredArchive = production.project -export const verifyServerPollRetiredArchiveCharge = production.verifyCharge export const openServerPollRetiredArchive = production.open +/** One accounting pass reuses the existing record-purpose signer. */ +export async function createServerPollRetiredArchiveChargeVerifier() { + const signer = await privateFingerprinterExisting(RECORD_PURPOSE) + return codec({ ...productionDependencies, proof: async (purpose, value) => { + if (purpose !== RECORD_PURPOSE) return refuse() + return signer(fingerprint(value)) + } }).verifyCharge +} /** Isolated component crypto composition. No public/runtime caller selects it. */ export const createServerPollRetiredArchiveForTests = (deps: Dependencies) => codec(Object.freeze({ ...deps })) diff --git a/backend/src/services/server-poll-storage-journal.ts b/backend/src/services/server-poll-storage-journal.ts index f46e60f5..744b8dcc 100644 --- a/backend/src/services/server-poll-storage-journal.ts +++ b/backend/src/services/server-poll-storage-journal.ts @@ -5,7 +5,8 @@ import { fingerprint, privateSecretFingerprint } from "../modules/mcp/security.j import type { ProbeJournalSql } from "./server-probe-journal.js" import type { ProbeStorageOptions } from "./server-probe-journal-maintenance.js" import type { ServerPollJournalContext } from "./server-poll-journal.js" -import { createServerPollStorage, PollStorageRefused, type PollStorageBinding } from "./server-poll-storage.js" +import { createServerPollStorage, createHolderBoundServerPollStorage, PollStorageRefused, type PollStorageBinding } from "./server-poll-storage.js" +import { isServerPollJournalOwner, type ServerPollJournalOwner } from "./server-partition-maintenance-lease.js" export class PollStorageSpent extends PollStorageRefused { readonly automaticReplay = false @@ -124,5 +125,9 @@ async function construct(raw: ServerPollJournalContext, rawOptions: ServerPollSt } export const createServerPollStorageJournal = (context: ServerPollJournalContext, options: ServerPollStorageJournalOptions) => construct(context, options, production,true) +export function createHolderBoundServerPollStorageJournal(context:ServerPollJournalContext,options:ServerPollStorageJournalOptions,owner:ServerPollJournalOwner) { + if(!isServerPollJournalOwner(owner))fail() + return construct(context,options,{...production,createStorage:binding=>createHolderBoundServerPollStorage(binding,owner)},true) +} /** Explicit trusted unit composition only; no request can select these hooks. */ export const createServerPollStorageJournalForTests = (context: ServerPollJournalContext, options: ServerPollStorageJournalOptions, dependencies: Pick & Partial>) => construct(context, options, { ...production, ...dependencies }) diff --git a/backend/src/services/server-poll-storage-retention.test.ts b/backend/src/services/server-poll-storage-retention.test.ts index 3ebb5b12..56485c86 100644 --- a/backend/src/services/server-poll-storage-retention.test.ts +++ b/backend/src/services/server-poll-storage-retention.test.ts @@ -15,13 +15,15 @@ function fixture(openRetired?: Parameters[] = [] const commands: { text: string; values: unknown[]; timeout: number }[] = [], releases: boolean[] = [], options: ProbeStorageOptions[] = [] - let settings: Record[] = [{ requests_retention_days: 30 }], fail = "", residual = "", removed: Record[] | undefined, hold: ReturnType | undefined + let settings: Record[] = [{ requests_retention_days: 30 }], fail = "", residual = "", removed: Record[] | undefined, hold: ReturnType | undefined,writer=true const entered = deferred() const run = () => collectExpiredServerPollStorageForTests({ now: () => now, connect: async accepted => { options.push(accepted) return { async query(text, values = [], queryOptions) { commands.push({ text, values, timeout: queryOptions!.timeoutMs }) if (text === fail) throw Error("PRIVATE_DRIVER_SECRET") + if (text.includes("pg_try_advisory_xact_lock_shared")) return { rows: [{ acquired:writer }] } + if (text.includes("current_setting('lock_timeout')")) return { rows: [{ lock_timeout: "0" }] } // GC transaction/ordering fixture only. Authentic compact MAC and complete // counter conservation are covered by archive-accounting component tests. if (text.startsWith("SELECT a.receipt_id AS admission_receipt_id") || text.startsWith("SELECT r.receipt_id FROM server_poll_archives")) return { rows: [] } @@ -35,16 +37,28 @@ function fixture(openRetired?: Parameters { hold = deferred(); return hold }, + return { run, now, candidates, retiredRows, commands, releases, options, entered: entered.promise, block: () => { hold = deferred(); return hold },setWriter:(value:boolean)=>{writer=value}, setting: (rows: Record[]) => { settings = rows }, fail: (text: string) => { fail = text }, residual: (table: string) => { residual = table }, removed: (rows: Record[]) => { removed = rows } } } +test("only a definite shared xact lock miss defers GC before POLL storage or a retention read",async()=>{ + const fixtureBlocked=fixture();fixtureBlocked.setWriter(false) + assert.deepEqual(await fixtureBlocked.run(),{collected:0,automaticReplay:false}) + const lock="SELECT pg_catalog.pg_try_advisory_xact_lock_shared($1::bigint) AS acquired" + assert.deepEqual(fixtureBlocked.commands.map(command=>command.text),["BEGIN",lock,"COMMIT"]) + const unexpected=fixture();unexpected.fail(lock) + await assert.rejects(unexpected.run(),PollStorageRefused) + assert.deepEqual(unexpected.releases,[true]) +}) + test("selected GC owns one bounded backend, locks current retention, deletes all children and verifies trigger refund outcome", async () => { const f = fixture() assert.deepEqual(await f.run(), { collected: 2, automaticReplay: false }) assert.equal(f.options.length, 1); assert.equal(f.options[0].deadlineAt, f.now + 20000) assert(f.commands.every(command => command.timeout >= 1 && command.timeout <= 5000)) const texts = f.commands.map(command => command.text), setting = texts.findIndex(text => text.includes("requests_retention_days")) + const writer = texts.findIndex(text => text.includes("pg_advisory_xact_lock_shared(")), storage = texts.findIndex(text => text.includes("server-poll-storage-v1")) + assert(writer > 0 && writer < storage && storage < setting) assert(texts[setting].endsWith("FOR SHARE")); assert(setting < texts.indexOf("SET LOCAL mm.poll_storage_gc='on'")) const selection = texts.find(text => text.startsWith("SELECT receipt_id,owner_id"))! assert(selection.includes("gc_not_before{},async sql=>{ + const deadlineAt=deps.now()+20000 + return runPollStorageTransaction(deps,{deadlineAt},async()=>{},async sql=>{ // Hold the authoritative setting against concurrent updates until COMMIT. // Each DELETE guard additionally checks its current value independently. const setting=(await sql.query("SELECT requests_retention_days FROM mcp_settings WHERE id=1 FOR SHARE")).rows @@ -62,6 +63,9 @@ async function collect(deps:PollStorageDependencies, openRetired:typeof openServ if((await sql.query(`SELECT receipt_id FROM ${table} WHERE receipt_id=ANY($1::uuid[]) LIMIT 1`,[ids])).rows.length)fail() } return {collected:ids.length,automaticReplay:false as const} + },async sql=>{ + if(!await tryAdmitPollStorageMaintenanceWriter(sql,"transaction"))return {deferred:{collected:0,automaticReplay:false as const}} + await admitPollStorageWriter(sql,deadlineAt) }) } export const collectExpiredServerPollStorage=()=>sharedDatabaseGate.request(()=>collect(production,openRetiredArchive)) diff --git a/backend/src/services/server-poll-storage.ts b/backend/src/services/server-poll-storage.ts index 320956b7..d8c0d15b 100644 --- a/backend/src/services/server-poll-storage.ts +++ b/backend/src/services/server-poll-storage.ts @@ -7,6 +7,8 @@ import { connectProbeStorage } from "./server-probe-storage-connection.js" import type { ProbeStorageConnection, ProbeStorageOptions, ProbeStorageSql } from "./server-probe-journal-maintenance.js" import { pollStorageScope, PollStorageRefused, type PollStorageScope } from "./server-poll-storage-model.js" import { assertServerPollArchiveAccounting } from "./server-poll-archive-accounting.js" +import type { ServerPollJournalOwner } from "./server-partition-maintenance-lease.js" +import { admitPartitionWriter, PARTITION_WRITER_ADVISORY_KEY } from "./partition-write-transaction.js" export { PollStorageRefused } export class PollStorageUnknown extends Error { @@ -29,9 +31,32 @@ const production:PollStorageDependencies={connect:connectProbeStorage,now:Date.n const fail=():never=>{throw new PollStorageRefused()} const lock="SELECT pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0))" +/** The writer fence precedes POLL's storage, operation and row locks. Restore + * the lock timeout before the runner sets its usual storage statement budget. */ +export async function admitPollStorageWriter(sql:ProbeStorageSql,deadlineAt:number):Promise{ + const settings=(await sql.query("SELECT pg_catalog.current_setting('lock_timeout') AS lock_timeout")).rows + if(settings.length!==1||typeof settings[0].lock_timeout!=="string")fail() + await admitPartitionWriter(sql,{deadlineAt:Math.min(deadlineAt,Date.now()+5000)}) + await sql.query("SELECT pg_catalog.set_config('lock_timeout',$1,true)",[settings[0].lock_timeout]) +} + +/** Only a definite false read from this exact lock query can defer maintenance. + * A SQL, provider, timeout, shape or acknowledgement failure stays an error. */ +export async function tryAdmitPollStorageMaintenanceWriter(sql:ProbeStorageSql,scope:"session"|"transaction"):Promise{ + const statement=scope==="session" + ?"SELECT pg_catalog.pg_try_advisory_lock_shared($1::bigint) AS acquired" + :"SELECT pg_catalog.pg_try_advisory_xact_lock_shared($1::bigint) AS acquired" + const {rows}=await sql.query(statement,[PARTITION_WRITER_ADVISORY_KEY]) + if(rows.length!==1||typeof rows[0].acquired!=="boolean")return fail() + return rows[0].acquired +} + +type PollStoragePrelude=(sql:ProbeStorageSql)=>Promise + /** Own one connector through callback and COMMIT, including non-SQL awaits. - * Leaked facades expire; no ROLLBACK or retry follows an uncertain COMMIT. */ -export async function runPollStorageTransaction(deps:PollStorageDependencies,options:ProbeStorageOptions,authorize:()=>Promise,work:(sql:ProbeStorageSql)=>Promise):Promise{ + * Leaked facades expire; no ROLLBACK or retry follows an uncertain COMMIT. + * A trusted maintenance caller can admit its writer on this same checkout before the storage lock. */ +export async function runPollStorageTransaction(deps:PollStorageDependencies,options:ProbeStorageOptions,authorize:()=>Promise,work:(sql:ProbeStorageSql)=>Promise,beforeStorageLock?:PollStoragePrelude):Promise{ deps=Object.freeze({connect:deps.connect,now:deps.now}) options=Object.freeze({deadlineAt:options.deadlineAt,signal:options.signal}) if(!Number.isSafeInteger(options.deadlineAt)||options.deadlineAt<=deps.now()||options.deadlineAt-deps.now()>180000||options.signal?.aborted)fail() @@ -58,13 +83,17 @@ export async function runPollStorageTransaction(deps:PollStorageDependencies, connection=await deps.connect(options) if(!active||options.signal?.aborted||deps.now()>=options.deadlineAt){close(true);fail()} await sql.query("BEGIN");begun=true - await sql.query("SELECT set_config('TimeZone','UTC',true),set_config('statement_timeout','5000ms',true)") - await sql.query(lock) const owned:ProbeStorageSql=Object.freeze({async query(text:string,values?:unknown[]){ const command=text.replace(/\/\*[\s\S]*?\*\/|--[^\r\n]*/g," ").trim() if(/^(?:BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE|START|PREPARE)\b|^SET\s+TRANSACTION\b/i.test(command)||command.includes(";"))fail() return sql.query(text,values) }}) + if(beforeStorageLock){ + const admission=await beforeStorageLock(owned) + if(admission){await sql.query("COMMIT");return admission.deferred} + } + await sql.query("SELECT set_config('TimeZone','UTC',true),set_config('statement_timeout','5000ms',true)") + await sql.query(lock) const result=await work(owned);await fence();await sql.query("COMMIT");return result }) try{return await Promise.race([completion,stopped])} @@ -75,7 +104,7 @@ export async function runPollStorageTransaction(deps:PollStorageDependencies, }finally{active=false;clearTimeout(timer);options.signal?.removeEventListener("abort",stop);close(broken||busy||!committed)} } -function factory(raw:PollStorageBinding,rawDeps:PollStorageDependencies){ +function factory(raw:PollStorageBinding,rawDeps:PollStorageDependencies,journalOwner?:ServerPollJournalOwner){ const deps=Object.freeze({connect:rawDeps.connect,now:rawDeps.now}) const scope=pollStorageScope(structuredClone(raw.scope),{now:deps.now()}),authorize=raw.authorize const connectionId=scope.kind==="mcp"?z.string().uuid().parse(raw.connectionId):raw.connectionId===null?null:fail() @@ -98,7 +127,10 @@ function factory(raw:PollStorageBinding,rawDeps:PollStorageDependencies){ } const run=async(options:ProbeStorageOptions,work:(sql:ProbeStorageSql)=>Promise)=>{ const accepted=Object.freeze({signal:options.signal,deadlineAt:Math.min(options.deadlineAt,scope.expiresAt)}) - try{return await runPollStorageTransaction(deps,accepted,()=>current(accepted),work)} + try{ + const authorize=()=>current(accepted) + return await (journalOwner?journalOwner.transaction(accepted,authorize,work):runPollStorageTransaction(deps,accepted,authorize,work)) + } catch(error){if(error instanceof PollStorageUnknown)poisoned=true;throw error} } return Object.freeze({ @@ -132,15 +164,17 @@ function factory(raw:PollStorageBinding,rawDeps:PollStorageDependencies){ * run() has released/destroyed its physical backend before this is called. */ async quarantine(){ const options={deadlineAt:deps.now()+10000} + journalOwner?.assertClosedForQuarantine() return runPollStorageTransaction(deps,options,async()=>{},async sql=>{ if(!await read(sql))fail() const rows=(await sql.query("SELECT receipt_id,identity_kind,execution_id,context_proof FROM server_poll_quarantines WHERE receipt_id=$1",[scope.receiptId])).rows if(rows.length){if(rows.length!==1||rows[0].identity_kind!==scope.kind||rows[0].execution_id!==scope.executionId||rows[0].context_proof!==scope.contextProof)fail();return} await sql.query("INSERT INTO server_poll_quarantines(receipt_id,identity_kind,execution_id,context_proof) VALUES($1,$2,$3,$4)",[scope.receiptId,scope.kind,scope.executionId,scope.contextProof]) - }) + },journalOwner?sql=>admitPollStorageWriter(sql,options.deadlineAt):undefined) }, }) } export const createServerPollStorage=(binding:PollStorageBinding)=>factory(binding,production) +export const createHolderBoundServerPollStorage=(binding:PollStorageBinding,owner:ServerPollJournalOwner)=>factory(binding,production,owner) /** Explicit unit/isolated SQL composition, never populated from a request. */ export const createServerPollStorageForTests=(binding:PollStorageBinding,deps:PollStorageDependencies)=>factory(binding,deps) diff --git a/backend/src/services/server-poll-terminal-archive.owned.test.ts b/backend/src/services/server-poll-terminal-archive.owned.test.ts index 641d4c20..5a9fbcc0 100644 --- a/backend/src/services/server-poll-terminal-archive.owned.test.ts +++ b/backend/src/services/server-poll-terminal-archive.owned.test.ts @@ -77,7 +77,7 @@ async function main() { } try { await applySqlMigrations(pool) - assert.equal(MIGRATION_MANIFEST.at(-1)?.file, "0055_mcp_backup_schedule_retirement.sql") + assert.equal(MIGRATION_MANIFEST.at(-1)?.file, "0056_mcp_manual_inverse_recovery_v8.sql") assert.equal(MIGRATION_MANIFEST.find(pin=>pin.file==="0041_server_poll_terminal_archives.sql")?.sha256,"fbb8935ce977cfc507801f01beb8f303d4817ed63c6bdd4c05799768dfaac9d4") assert.equal((await pool.query("SELECT count(*)::int AS n FROM schema_migrations")).rows[0].n, MIGRATION_MANIFEST.length) assert.equal((await pool.query("SELECT count(*)::int AS n FROM server_poll_storage_admissions")).rows[0].n, 0) diff --git a/backend/src/services/server-poll-terminal-compaction.test.ts b/backend/src/services/server-poll-terminal-compaction.test.ts index 14728d26..a89243f5 100644 --- a/backend/src/services/server-poll-terminal-compaction.test.ts +++ b/backend/src/services/server-poll-terminal-compaction.test.ts @@ -7,6 +7,7 @@ import ts from "typescript" import { z } from "zod" import * as model from "./server-poll-storage-model.js" import * as errorProvenance from "./server-poll-error-provenance.js" +import { admitPartitionWriter, PARTITION_WRITER_ADVISORY_KEY } from "./partition-write-transaction.js" import type { PollStorageDependencies } from "./server-poll-storage.js" /** Actual compactor and transaction dispatcher, explicit SQL/codec/accounting @@ -29,9 +30,9 @@ function load(code: string, modules: Record) { } function fixture() { const now = Date.now(), receiptId = randomUUID(), operationId = randomUUID(), connectionId = randomUUID() - let state: State = { claim: null, archive: null, audits: [] }, connects = 0, commits = 0, createCalls = 0, openCalls = 0, accountCalls = 0 + let state: State = { claim: null, archive: null, audits: [] }, connects = 0, transactions=0, commits = 0, createCalls = 0, openCalls = 0, accountCalls = 0 let eligible = true, sourceMissing = false, accountingFails = false, preflight: "valid" | "oversize" | "invalid" = "valid", secondOversize = false - let lostCommit = 0, commitPersists = true, badPublishedSize = false, badPublishedText = false, auditFails = false + let lostCommit = 0, commitPersists = true, badPublishedSize = false, badPublishedText = false, auditFails = false, writer: boolean|Error=true const commands: Command[] = [], releases: { tx: number; unsafe: boolean }[] = [], events: string[] = [] class ArchiveRefused extends Error {} const forbiddenConnector = async (): Promise => { throw Error("Production connector must never run") } @@ -40,11 +41,13 @@ function fixture() { "node:crypto": { randomUUID }, zod: { z }, "./server-probe-storage-connection.js": { connectProbeStorage: forbiddenConnector }, "./server-poll-storage-model.js": model, "./server-poll-archive-accounting.js": { assertServerPollArchiveAccounting: accounting }, "./server-poll-error-provenance.js": errorProvenance, + "./partition-write-transaction.js": { admitPartitionWriter, PARTITION_WRITER_ADVISORY_KEY }, }) const archives = new Map() const api = load(compactorCode, { "node:crypto": { randomUUID }, "./shared-database-gate.js": { sharedDatabaseGate: { request: (work: () => unknown) => work() } }, "./server-probe-storage-connection.js": { connectProbeStorage: forbiddenConnector }, "./server-poll-storage.js": storage, + "./partition-write-transaction.js": { PARTITION_WRITER_ADVISORY_KEY }, "./server-poll-archive-accounting.js": { assertServerPollArchiveAccounting: accounting }, "./server-poll-terminal-archive.js": { ServerPollArchiveRefused: ArchiveRefused, @@ -67,22 +70,26 @@ function fixture() { }, }) as unknown as Api const deps: PollStorageDependencies = { now: () => now, connect: async options => { - const tx = ++connects; let transaction: State | undefined, closed = false - assert.equal(options.deadlineAt, now + 20000) + const owner = ++connects; let tx=0, transaction: State | undefined, closed = false + assert.equal(options.deadlineAt, now + 40000) return { async query(text, values = [], queryOptions) { values = Array.from(values) // Normalize the VM caller's array realm only. assert(!closed); assert(queryOptions && queryOptions.timeoutMs >= 1 && queryOptions.timeoutMs <= 5000) + if(text==="BEGIN")tx=++transactions commands.push({ tx, text, values }); events.push(tx + ":" + text) if (text === "BEGIN") { transaction = structuredClone(state); return { rows: [] } } if (text === "ROLLBACK") { transaction = undefined; return { rows: [] } } + if (text.includes("pg_advisory_unlock_shared")) {assert.equal(transaction,undefined);return {rows:[{released:true}]}} assert(transaction) + if (text.includes("pg_try_advisory_lock_shared")) {if(writer instanceof Error)throw writer;return {rows:[{acquired:writer}]}} if (text === "COMMIT") { commits++; if (commits !== lostCommit || commitPersists) state = structuredClone(transaction) transaction = undefined if (commits === lostCommit) throw Error("PRIVATE_DRIVER_COMMIT_ACK_LOST") return { rows: [] } } - if (text.startsWith("SELECT set_config(") || text.includes("pg_advisory_xact_lock") || text === ACCOUNTING) return { rows: [] } + if (text.includes("current_setting('lock_timeout')")) return { rows: [{ lock_timeout: "0" }] } + if (text.startsWith("SELECT set_config(") || text.startsWith("SELECT pg_catalog.set_config(") || text.startsWith("SET LOCAL ") || text.includes("pg_advisory_xact_lock") || text === ACCOUNTING) return { rows: [] } if (text.startsWith("SELECT a.receipt_id FROM server_poll_storage_admissions")) { assert(text.includes("a.compaction_claim IS NULL")); assert(text.includes("a.storage_state='full'")) return { rows: eligible && !transaction.claim && !transaction.archive ? [{ receipt_id: receiptId }] : [] } @@ -113,10 +120,10 @@ function fixture() { assert.equal(values[1], "poll.history.compacted"); transaction.audits.push({ actor: values[0], connection: values[2], payload: JSON.parse(String(values[3])) }); return { rows: [] } } throw Error("Unapproved fixture SQL: " + text) - }, release(unsafe = false) { assert(!closed); closed = true; transaction = undefined; releases.push({ tx, unsafe }) } } + }, release(unsafe = false) { assert(!closed); closed = true; transaction = undefined; releases.push({ tx:owner, unsafe }) } } } } return { run: () => api.compactCompletedServerPollStorageForTests(deps), commands, releases, events, state: () => structuredClone(state), - counts: () => ({ connects, commits, createCalls, openCalls, accountCalls }), Unknown: storage.PollStorageUnknown as new () => Error, + counts: () => ({ connects, commits, createCalls, openCalls, accountCalls }), Unknown: storage.PollStorageUnknown as new () => Error,setWriter:(value:boolean|Error)=>{writer=value}, configure(options: { eligible?: boolean; sourceMissing?: boolean; accountingFails?: boolean; preflight?: typeof preflight; secondOversize?: boolean; lostCommit?: number; commitPersists?: boolean; badPublishedSize?: boolean; badPublishedText?: boolean; auditFails?: boolean }) { eligible = options.eligible ?? eligible; sourceMissing = options.sourceMissing ?? sourceMissing; accountingFails = options.accountingFails ?? accountingFails preflight = options.preflight ?? preflight; secondOversize = options.secondOversize ?? secondOversize; lostCommit = options.lostCommit ?? lostCommit @@ -133,6 +140,17 @@ test("idle tick skips accounting and codec/key/native dependencies", async () => assert.equal(claimed(f).length, 0); assert.equal(published(f).length, 0) }) +test("only a definite shared session lock miss defers before POLL storage, and SQL failures remain errors",async()=>{ + const blocked=fixture();blocked.setWriter(false) + assert.deepEqual({...await blocked.run()},{scanned:0,compacted:0,skipped:0,automaticReplay:false}) + assert.deepEqual(blocked.commands.map(command=>command.text),["BEGIN","SELECT pg_catalog.pg_try_advisory_lock_shared($1::bigint) AS acquired","COMMIT"]) + assert.deepEqual(blocked.releases,[{tx:1,unsafe:false}]) + const unexpected=fixture();unexpected.setWriter(Error("owned unexpected provider failure")) + await assert.rejects(unexpected.run(),model.PollStorageRefused) + assert.deepEqual(unexpected.releases,[{tx:1,unsafe:true}]) + assert.equal(claimed(unexpected).length,0) +}) + test("ineligible SQL source, oversize and invalid preflight skip without consuming claim", async () => { for (const options of [{ sourceMissing: true }, { preflight: "oversize" as const }, { preflight: "invalid" as const }]) { const f = fixture(); f.configure(options); const result = await f.run() @@ -161,7 +179,7 @@ test("phase1 COMMIT ACK loss never starts phase2 or inserts archive, for either test("normal two acknowledged COMMITs publish exact readback and audit in the same transaction", async () => { const f = fixture(), result = await f.run() assert.equal(result.compacted, 1); assert.equal(result.automaticReplay, false) - assert.deepEqual(f.counts(), { connects: 2, commits: 2, createCalls: 2, openCalls: 2, accountCalls: 3 }) + assert.deepEqual(f.counts(), { connects: 1, commits: 2, createCalls: 2, openCalls: 2, accountCalls: 3 }) const firstCommit = f.events.indexOf("1:COMMIT"), secondBegin = f.events.indexOf("2:BEGIN") assert(firstCommit >= 0 && firstCommit < secondBegin) for (const tx of [1, 2]) { @@ -175,7 +193,7 @@ test("normal two acknowledged COMMITs publish exact readback and audit in the sa assert(f.events.indexOf("open:2") < f.events.findIndex(event => event.startsWith("2:INSERT INTO mcp_audit"))) assert.equal(f.state().audits.length, 1); assert.deepEqual(f.state().audits[0].payload, { operationId: f.state().archive!.execution_id, receiptId: f.state().archive!.receipt_id, archiveBytes: f.state().archive!.ciphertext_bytes, sourceJournalRows: 6, originalRetentionDays: 30, automaticReplay: false }) - assert.deepEqual(f.releases, [{ tx: 1, unsafe: false }, { tx: 2, unsafe: false }]) + assert.deepEqual(f.releases, [{ tx: 1, unsafe: false }]) }) test("phase2 COMMIT ACK loss leaves spent full or complete archive, never rollback or automatic resume", async () => { @@ -184,7 +202,7 @@ test("phase2 COMMIT ACK loss leaves spent full or complete archive, never rollba await assert.rejects(f.run(), f.Unknown) assert(f.state().claim); assert.equal(!!f.state().archive, commitPersists); assert.equal(f.state().audits.length, commitPersists ? 1 : 0) assert(!f.commands.some(command => command.text === "ROLLBACK")); assert.equal(published(f).length, 1) - assert.deepEqual(f.releases, [{ tx: 1, unsafe: false }, { tx: 2, unsafe: true }]) + assert.deepEqual(f.releases, [{ tx: 1, unsafe: true }]) const counts = f.counts(); const next = await f.run() assert.equal(next.scanned, 0); assert.equal(next.compacted, 0); assert.equal(claimed(f).length, 1); assert.equal(published(f).length, 1) assert.equal(f.counts().createCalls, counts.createCalls); assert.equal(f.counts().accountCalls, counts.accountCalls) diff --git a/backend/src/services/server-poll-terminal-compaction.ts b/backend/src/services/server-poll-terminal-compaction.ts index 179fd5a6..b79a0492 100644 --- a/backend/src/services/server-poll-terminal-compaction.ts +++ b/backend/src/services/server-poll-terminal-compaction.ts @@ -3,7 +3,9 @@ import { randomUUID } from "node:crypto" import { sharedDatabaseGate } from "./shared-database-gate.js" import { connectProbeStorage } from "./server-probe-storage-connection.js" -import { runPollStorageTransaction, PollStorageRefused, type PollStorageDependencies } from "./server-poll-storage.js" +import { admitPollStorageWriter, runPollStorageTransaction, tryAdmitPollStorageMaintenanceWriter, PollStorageRefused, type PollStorageDependencies } from "./server-poll-storage.js" +import { PARTITION_WRITER_ADVISORY_KEY } from "./partition-write-transaction.js" +import type { ProbeStorageConnection, ProbeStorageSql } from "./server-probe-journal-maintenance.js" import { assertServerPollArchiveAccounting } from "./server-poll-archive-accounting.js" import { createServerPollTerminalArchive, openServerPollTerminalArchive, ServerPollArchiveRefused, type ServerPollTerminalArchive } from "./server-poll-terminal-archive.js" @@ -39,9 +41,55 @@ function insert(archive: ServerPollTerminalArchive) { return { text: `INSERT INTO server_poll_archives(${names.join(",")}) VALUES(${names.map((_name, index) => "$" + (index + 1)).join(",")}) RETURNING receipt_id`, values: names.map(name => archive[name]) } } -async function compact(deps: PollStorageDependencies) { +/** One physical session keeps the shared writer lock across the independently + * acknowledged claim and publication. An unsafe virtual release destroys the + * physical owner, so a spent or uncertain claim cannot open another backend. */ +function compactionWriter(deps:PollStorageDependencies,leaseDeadline:number){ + let physical:ProbeStorageConnection|undefined,borrowed=false,acquired=false,closed=false + const release=(unsafe:boolean)=>{ + closed=true + const connection=physical;physical=undefined + connection?.release(unsafe) + } + const connect:PollStorageDependencies["connect"]=async options=>{ + if(closed||borrowed)fail() + borrowed=true + try{ + const connection=physical??await deps.connect({...options,deadlineAt:leaseDeadline}) + if(closed){connection.release(true);return fail()} + physical=connection + let released=false + return { + query:(...args)=>{if(released||closed)return Promise.reject(new PollStorageRefused());return connection.query(...args)}, + release(unsafe=false){if(released)return;released=true;borrowed=false;if(unsafe)release(true)}, + } + }catch(error){borrowed=false;throw error} + } + return { + deps:{now:deps.now,connect}, + async tryAcquire(sql:ProbeStorageSql){acquired=await tryAdmitPollStorageMaintenanceWriter(sql,"session");return acquired}, + async finish(deadlineAt:number){ + if(!physical||borrowed)return fail() + if(acquired){ + const remaining=Math.min(5000,deadlineAt-deps.now()) + if(remaining<1)return fail() + const {rows}=await physical.query("SELECT pg_catalog.pg_advisory_unlock_shared($1::bigint) AS released",[PARTITION_WRITER_ADVISORY_KEY],{timeoutMs:remaining}) + if(rows.length!==1||rows[0].released!==true)return fail() + } + release(false) + }, + destroy(){if(!closed)release(true)}, + } +} + +async function compact(deps:PollStorageDependencies){ + const firstDeadline=deps.now()+20000,writer=compactionWriter(deps,firstDeadline+20000) + try{return await compactOnWriter(writer,firstDeadline)}finally{writer.destroy()} +} + +async function compactOnWriter(writer:ReturnType,firstDeadline:number) { + const {deps}=writer let scanned = 0, skipped = 0 - const firstDeadline = deps.now() + 20000 const claimed = await runPollStorageTransaction(deps, { deadlineAt: firstDeadline }, async () => {}, async sql => { const candidates = (await sql.query(CANDIDATES)).rows if (candidates.length > 128) fail() @@ -75,12 +123,16 @@ async function compact(deps: PollStorageDependencies) { return { receiptId: candidate.receipt_id, token } } return undefined + }, async sql => { + if(!await writer.tryAcquire(sql))return {deferred:undefined} + await admitPollStorageWriter(sql,firstDeadline) }) - if (!claimed) return { scanned, compacted: 0, skipped, automaticReplay: false as const } + if (!claimed){await writer.finish(firstDeadline);return { scanned, compacted: 0, skipped, automaticReplay: false as const }} // This branch is reached ONLY after the separate claim COMMIT was ACKed. // Any loss after here leaves either a complete archive or a charged spent // full row. Fresh workers select compaction_claim IS NULL and never replay it. - const compacted = await runPollStorageTransaction(deps, { deadlineAt: deps.now() + 20000 }, async () => {}, async sql => { + const secondDeadline = deps.now() + 20000 + const compacted = await runPollStorageTransaction(deps, { deadlineAt: secondDeadline }, async () => {}, async sql => { await assertServerPollArchiveAccounting(sql) const admission = (await sql.query("SELECT receipt_id FROM server_poll_storage_admissions WHERE receipt_id=$1::uuid AND storage_state='full' AND compaction_claim=$2::uuid AND compaction_claimed_at IS NOT NULL AND compaction_claim_xid IS NOT NULL FOR UPDATE", [claimed.receiptId, claimed.token])).rows if (admission.length !== 1) fail() @@ -104,7 +156,8 @@ async function compact(deps: PollStorageDependencies) { originalRetentionDays: archive.retention_days, automaticReplay: false })]) await assertServerPollArchiveAccounting(sql) return 1 - }) + }, sql => admitPollStorageWriter(sql, secondDeadline)) + await writer.finish(secondDeadline) return { scanned, compacted, skipped: skipped + (compacted ? 0 : 1), automaticReplay: false as const } } export const compactCompletedServerPollStorage = () => sharedDatabaseGate.request(() => compact(production)) diff --git a/backend/src/services/server-probe-journal-maintenance.ts b/backend/src/services/server-probe-journal-maintenance.ts index ee5dcced..29207a82 100644 --- a/backend/src/services/server-probe-journal-maintenance.ts +++ b/backend/src/services/server-probe-journal-maintenance.ts @@ -4,6 +4,7 @@ * trusted original-expiry entry gates and the guarded v2 schema proposal. */ import { randomUUID } from "node:crypto" import { probeMetadataGcEligible } from "./server-probe-journal-maintenance-model.js" +import { admitPartitionWriter } from "./partition-write-transaction.js" export interface ProbeStorageSql { query(text:string,values?:unknown[],options?:{signal?:AbortSignal;timeoutMs:number}):Promise<{rows:Record[]}> } export interface ProbeStorageConnection extends ProbeStorageSql { @@ -71,16 +72,30 @@ export function createProbeJournalStorage(deps:ProbeStorageDependencies){ must(configured) must(Number.isSafeInteger(deps.safetyWindowMs)&&deps.safetyWindowMs>=60000&&deps.safetyWindowMs<=86400000) const policy=validateProbeStoragePolicy({retention_days:configured.retentionDays,global_bytes:configured.globalBytes,owner_bytes:configured.ownerBytes,global_files:configured.globalFiles,owner_files:configured.ownerFiles,global_admissions:configured.globalAdmissions,owner_admissions:configured.ownerAdmissions}) - const run=async(ownerId:string,operation:"claim"|"publish"|"read"|"purge"|"cancel",bound:ProbeStorageScope|undefined,options:ProbeStorageOptions,work:(sql:ProbeStorageSql,policy:ProbeStoragePolicy,bind:(value:ProbeStorageScope)=>Promise)=>Promise):Promise=>{ + const run=async(ownerId:string,operation:"claim"|"publish"|"read"|"purge"|"cancel",bound:ProbeStorageScope|undefined,options:ProbeStorageOptions,work:(sql:ProbeStorageSql,policy:ProbeStoragePolicy,bind:(value:ProbeStorageScope)=>Promise)=>Promise,mode:"default"|"expiry-write"="default"):Promise=>{ owner(ownerId);must(Number.isFinite(options.deadlineAt)&&options.deadlineAt>now()) let authority:string|undefined const fence=async()=>{options.signal?.throwIfAborted();must(now(){await fence();const result=await connection.query(text,values,{signal:options.signal,timeoutMs:Math.max(1,Math.min(10000,options.deadlineAt-now()))});await fence();return result}} + const query:ProbeStorageSql["query"]=(text,values)=>connection.query(text,values,{signal:options.signal,timeoutMs:Math.max(1,Math.min(10000,options.deadlineAt-now()))}) + const sql:ProbeStorageSql={query:async(text,values)=>{await fence();const result=await query(text,values);await fence();return result}} try{ - await sql.query("BEGIN");began=true + if(mode==="expiry-write"||operation==="claim"||operation==="publish"||operation==="cancel"){ + // Acquire the common key before authority checks can retain a relation + // lock on this backend and before the secondary probe-storage lock. + await fence() + await query("BEGIN") + try{await admitPartitionWriter({query},{signal:options.signal,deadlineAt:Date.now()+Math.min(options.deadlineAt-now(),1000)})} + catch{began=true;throw new ProbeStorageRefused()} + if(mode!=="expiry-write"){ + began=true + await query("SET LOCAL lock_timeout TO DEFAULT") + } + await fence() + }else await sql.query("BEGIN") + began=true await sql.query("SET LOCAL TimeZone='UTC'") await sql.query(`SET LOCAL statement_timeout='${Math.max(1,Math.min(10000,options.deadlineAt-now()))}ms'`) // Shared ordering across EVERY replica, admission, finish and purge. @@ -230,7 +245,7 @@ export function createProbeJournalStorage(deps:ProbeStorageDependencies){ await sql.query("/* probe-storage:gc-owner-counter */ DELETE FROM server_probe_storage_usage WHERE scope=$1 AND admissions=0 AND reserved_files=0 AND reserved_bytes=0",["owner:"+ownerId]) const absent=(await sql.query("/* probe-storage:gc-readback */ SELECT a.receipt_id FROM server_probe_storage_admissions a WHERE receipt_id=ANY($1::uuid[]) UNION ALL SELECT e.receipt_id FROM server_probe_executions e WHERE receipt_id=ANY($1::uuid[]) UNION ALL SELECT s.receipt_id FROM server_probe_execution_stages s WHERE receipt_id=ANY($1::uuid[]) UNION ALL SELECT q.receipt_id FROM server_probe_execution_quarantines q WHERE receipt_id=ANY($1::uuid[]) UNION ALL SELECT p.receipt_id FROM server_probe_execution_payloads p WHERE receipt_id=ANY($1::uuid[])",[ids])).rows;must(absent.length===0) return {collected:ids.length,automaticReplay:false as const} - }) + },"expiry-write") }, /** DRAFT: only expired cancellation records of terminal real own diagnostic * operations. Does not delete operations, plans, connections or audit. */ diff --git a/backend/src/services/server-probe-legacy-inspection.sql-helper.ts b/backend/src/services/server-probe-legacy-inspection.sql-helper.ts index 6092b295..7e41d230 100644 --- a/backend/src/services/server-probe-legacy-inspection.sql-helper.ts +++ b/backend/src/services/server-probe-legacy-inspection.sql-helper.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict" import { randomUUID } from "node:crypto" import { basename, dirname, resolve } from "node:path" +import type { DatabaseSourceDeploymentPin } from "../db/database-source-binding.js" +import type { DatabaseSourceObservation } from "../db/database-source-observation.js" /** Actual private host acceptance against this runner's disposable legacy DB. */ -export async function acceptLegacyProbeInspectionSql() { +export async function acceptLegacyProbeInspectionSql(issue: (source: DatabaseSourceObservation["source"]) => Promise) { assert.equal(process.env.MMAPP_OWN_PROBE_STORAGE_JOURNAL, "true") const url = new URL(process.env.DATABASE_URL ?? "") assert.equal(url.hostname, "127.0.0.1"); assert.equal(url.pathname, "/postgres") @@ -23,6 +25,19 @@ export async function acceptLegacyProbeInspectionSql() { assert.equal(paired.sourceObservation.generationAuthenticated, false) assert.equal(paired.sourceObservation.ready, false) assert(Object.isFrozen(paired) && Object.isFrozen(paired.sourceObservation)) + assert.equal(paired.sourceBinding, undefined) + const pin = await issue(paired.sourceObservation.source) + const bound = await inspectLegacyServerProbeSource(actor, { deadlineAt: Date.now() + 30000 }, pin) + assert.equal(bound.summary.counts.verified, 2) + assert.equal(bound.sourceBinding?.headSha256, pin.currentHeadSha256) + assert.equal(bound.sourceBinding?.genuineSourceFactsMatched, true) + assert.equal(bound.sourceBinding?.generationAuthenticated, false) + assert.equal(bound.sourceBinding?.allWriterDrainAuthenticated, false) + assert.equal(bound.conversionAllowed, false) + const wrongSource = await issue({ ...paired.sourceObservation.source, databaseName: "different-fixture" }) + await assert.rejects(inspectLegacyServerProbeSource(actor, { deadlineAt: Date.now() + 30000 }, wrongSource), McpError) + await assert.rejects(inspectLegacyServerProbeSource(actor, { deadlineAt: Date.now() + 30000 }, + { ...pin, currentHeadSha256: "0".repeat(64) }), McpError) await pool.query("INSERT INTO mcp_owner_revocations(owner_id,invalid_before) VALUES($1,$2)", [actor.id, new Date()]) await assert.rejects(inspectLegacyServerProbeSnapshot(actor, { deadlineAt: Date.now() + 30000 }), McpError) const postActor = { ...actor, id: "owned-inspection-post-" + randomUUID() } @@ -75,7 +90,8 @@ export async function acceptLegacyProbeInspectionSql() { assert.equal(remaining, false) assert.equal((await pool.query("SELECT count(*)::int AS n FROM server_probe_executions")).rows[0].n, before) return { actualPrivateProductionHost: true, actualPortalRevocation: true, productionCrypto: true, - twoLegacyReceiptsAuthenticated: true, actualSourceAndLegacyDataPairedInOwnedSnapshot: true, sourceGenerationStillUnauthenticated: true, realPostCollectionRevocationRefused: true, postRaceUsesTrustedCollectorObserver: true, actualRelationLock: true, abortedBeforeUnlockWithin2Seconds: true, + twoLegacyReceiptsAuthenticated: true, actualSourceAndLegacyDataPairedInOwnedSnapshot: true, signedBindingMatchedInOwnedSnapshot: true, + wrongSourceAndHeadRefused: true, sourceGenerationStillUnauthenticated: true, realPostCollectionRevocationRefused: true, postRaceUsesTrustedCollectorObserver: true, actualRelationLock: true, abortedBeforeUnlockWithin2Seconds: true, cancelledBackendGoneAfterUnlock: true, unchangedAnchorCount: true, nativeCalls: 0, publicRegistration: false, migrationReady: false, conversionAllowed: false } } diff --git a/backend/src/services/server-probe-legacy-inspection.ts b/backend/src/services/server-probe-legacy-inspection.ts index 03ed5a19..e9afb667 100644 --- a/backend/src/services/server-probe-legacy-inspection.ts +++ b/backend/src/services/server-probe-legacy-inspection.ts @@ -1,6 +1,7 @@ /** Internal authenticated operator observation. No transport registration, borrowed * transaction, conversion capability, or caller-selected storage is provided. */ import type { PoolClient } from "pg" +import { createDatabaseSourceBindingVerifier, type DatabaseSourceDeploymentPin } from "../db/database-source-binding.js" import { captureDatabaseSourceObservation, assertDatabaseSourceObservationCurrent } from "../db/database-source-observation.js" import { hasPermission, type AuthUser } from "../lib/permissions.js" import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js" @@ -131,20 +132,27 @@ function createInspection(deps: Dependencies) { export const inspectLegacyServerProbeSnapshot = createInspection({ connect: connectProbeStorage, portal: assertPortalClaimsCurrent, collect: collectLegacyServerProbeSnapshot }) -/** Private source/data pairing for future enrollment. Database identifiers stay - * internal; this is not an AI tool result or an authenticated generation. */ -export async function inspectLegacyServerProbeSource(actor: AuthUser, options: LegacyServerProbeSnapshotOptions) { +/** Private source/data pairing for future enrollment. Only trusted operator + * assembly may supply a deployment pin. No request fields or environment fallback. */ +export async function inspectLegacyServerProbeSource(actor: AuthUser, options: LegacyServerProbeSnapshotOptions, + deploymentPin?: DatabaseSourceDeploymentPin) { + const pin = deploymentPin === undefined ? undefined : { ...deploymentPin } let source: Awaited> | undefined + let sourceBinding: Awaited["verify"]>> | undefined const summary = await createInspection({ connect: connectProbeStorage, portal: assertPortalClaimsCurrent, collect: async (sql, boundedOptions) => { const original = await captureDatabaseSourceObservation(sql, boundedOptions) + const verifier = pin === undefined ? undefined : createDatabaseSourceBindingVerifier(pin) + await verifier?.verify(sql, original, boundedOptions) const result = await collectLegacyServerProbeSnapshot(sql, boundedOptions) + sourceBinding = await verifier?.verify(sql, original, boundedOptions) await assertDatabaseSourceObservationCurrent(sql, original, boundedOptions) source = original return result } })(actor, options) if (!source) throw unavailable() - return Object.freeze({ sourceObservation: source, summary, migrationReady: false as const, conversionAllowed: false as const, + return Object.freeze({ sourceObservation: source, summary, ...(sourceBinding ? { sourceBinding } : {}), + migrationReady: false as const, conversionAllowed: false as const, requiresExternalSourceBinding: true as const, generationAuthenticated: false as const }) } diff --git a/backend/src/services/server-probe-operation-retirement.owned.test.ts b/backend/src/services/server-probe-operation-retirement.owned.test.ts index b716d0eb..5b1e9fde 100644 --- a/backend/src/services/server-probe-operation-retirement.owned.test.ts +++ b/backend/src/services/server-probe-operation-retirement.owned.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict" import { randomUUID } from "node:crypto" +import { setTimeout as delay } from "node:timers/promises" +import type { PoolClient } from "pg" import { assertOwnedBandwidthSql, prepareOwnedBandwidthState } from "./bandwidth-admission-process.sql-helper.js" assertOwnedBandwidthSql() @@ -12,13 +14,46 @@ const { authenticateMcpToken, createMcpConnection } = await import("../modules/m const { applyPlan, invokeMcpTool } = await import("../modules/mcp/runtime.js") const { encryptSecret, McpError } = await import("../modules/mcp/security.js") const { serverProbeRetiredFixture } = await import("./server-probe-retired-source.fixture.js") -const { captureServerProbeTerminalProof, collectExpiredRetiredServerProbe, retireCompletedServerProbeOperation, +const { capturePublishedServerProbeTerminalProof, captureServerProbeTerminalProof, collectExpiredRetiredServerProbe, retireCompletedServerProbeOperation, retireExpiredServerProbeOperation } = await import("./server-probe-operation-retirement.js") -const { createProbeJournalStorage } = await import("./server-probe-journal-maintenance.js") +const { createProbeJournalStorage, ProbeStorageRefused } = await import("./server-probe-journal-maintenance.js") +const { PARTITION_WRITER_ADVISORY_KEY } = await import("./partition-write-transaction.js") const { connectProbeStorage } = await import("./server-probe-storage-connection.js") const { PROBE_STORAGE_LIMITS } = await import("../modules/mcp/diagnostic-cancellation-storage.js") -const { retainServerProbeStorage } = await import("./server-probe-retention.js") +const { createServerProbeRetentionForTests, retainServerProbeStorage } = await import("./server-probe-retention.js") const { tickMcpWorkers } = await import("../modules/mcp/workers.js") +const { Client } = await import("pg") + +async function assertWaitsBeforeSecondary(start:()=>Promise, checkLocks:(sql:PoolClient)=>Promise, checkUnchanged:(sql:PoolClient)=>Promise) { + const exclusive = await pool.connect() + let blocked:Promise|undefined + try { + await exclusive.query("SELECT pg_advisory_lock($1::bigint)",[PARTITION_WRITER_ADVISORY_KEY]) + await exclusive.query("BEGIN") + blocked = start() + void blocked.catch(() => {}) + let waiting = false + for (const deadline=Date.now()+3000; !waiting&&Date.now() {}) } finally { + try { + await exclusive.query("ROLLBACK") + assert.equal((await exclusive.query("SELECT pg_advisory_unlock($1::bigint) AS unlocked",[PARTITION_WRITER_ADVISORY_KEY])).rows[0].unlocked,true) + } finally { exclusive.release(true) } + } + } +} try { await applySqlMigrations(pool) @@ -73,6 +108,22 @@ try { } }) if (mode === "default-young" || mode === "default-old" || mode === "default-unproven" || mode === "default-spent") { await assert.rejects(retireExpiredServerProbeOperation(target)) + if (mode === "default-old") { + const storageState = async (sql: Pick) => (await sql.query(`SELECT + (SELECT to_jsonb(a) FROM public.server_probe_storage_admissions a WHERE receipt_id=$1) AS admission, + (SELECT receipt_proof FROM public.server_probe_execution_payloads WHERE receipt_id=$1) AS payload_proof, + (SELECT jsonb_agg(to_jsonb(u) ORDER BY u.scope) FROM public.server_probe_storage_usage u + WHERE u.scope IN ('global',$2)) AS counters`,[target.receiptId,"owner:"+actor.id])).rows[0] + const beforeExpiry = await storageState(pool) + assert(beforeExpiry.admission && beforeExpiry.payload_proof) + // Isolate the real retention transaction from its independent + // best-effort pre-capture, which takes its own common writer lock. + const collect = createServerProbeRetentionForTests(connectProbeStorage) + await assertWaitsBeforeSecondary(collect, async exclusive => { + await exclusive.query("LOCK TABLE public.mcp_settings IN ACCESS EXCLUSIVE MODE NOWAIT") + await exclusive.query("LOCK TABLE public.server_probe_storage_usage IN ACCESS EXCLUSIVE MODE NOWAIT") + }, async exclusive => assert.deepEqual(await storageState(exclusive),beforeExpiry)) + } if (mode === "default-unproven") { // This original still qualifies for normal payload expiry, but cannot // be authenticated as a dependency-free retirement source. @@ -136,7 +187,44 @@ try { await assert.rejects(applyPlan(principal,o.plan_id,key),error=>error instanceof McpError&&error.code==="OPERATION_HISTORY_EXPIRED") return } - assert.deepEqual(await retireCompletedServerProbeOperation(target), { retired:true,automaticReplay:false }) + if (mode === "retained") { + const assertOriginalUnlocked = async (exclusive: PoolClient) => { + assert.equal((await exclusive.query("SELECT pg_try_advisory_xact_lock(hashtextextended($1,0)) AS locked",["mcp-operation:"+target.operationId])).rows[0].locked,true) + assert.equal((await exclusive.query("SELECT id FROM public.mcp_connections WHERE id=$1 FOR UPDATE NOWAIT",[target.connectionId])).rows.length,1) + assert.equal((await exclusive.query("SELECT id FROM public.mcp_operations WHERE id=$1 FOR UPDATE NOWAIT",[target.operationId])).rows.length,1) + } + const captureState = async (sql: Pick) => (await sql.query(`SELECT to_jsonb(o) AS operation, + to_char(o.finished_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS finished_at, + (SELECT receipt_id FROM public.server_probe_terminal_proofs WHERE operation_id=o.id) AS proof_receipt + FROM public.mcp_operations o WHERE id=$1`,[target.operationId])).rows[0] + const beforeCapture = await captureState(pool) + assert.equal(beforeCapture.proof_receipt,null) + const published = { ...target, finishedAt:beforeCapture.finished_at } + await assertWaitsBeforeSecondary(() => capturePublishedServerProbeTerminalProof(published),assertOriginalUnlocked, + async exclusive => assert.deepEqual(await captureState(exclusive),beforeCapture)) + assert.deepEqual(await capturePublishedServerProbeTerminalProof(published),{captured:true,automaticReplay:false}) + assert.deepEqual(await captureState(pool),{...beforeCapture,proof_receipt:target.receiptId}) + assert.deepEqual(await invokeMcpTool(principal,"mm_server_probe_receipt_get",request),live) + await assertWaitsBeforeSecondary(() => retireCompletedServerProbeOperation(target), assertOriginalUnlocked, async exclusive => { + const {rows:[state]} = await exclusive.query(`SELECT + EXISTS(SELECT 1 FROM mcp_operations WHERE id=$1) AS live, + EXISTS(SELECT 1 FROM server_probe_retirement_claims WHERE receipt_id=$2) AS claim, + EXISTS(SELECT 1 FROM server_probe_retired_sources WHERE receipt_id=$2) AS archive, + EXISTS(SELECT 1 FROM mcp_operation_replay_tombstones WHERE operation_id=$1) AS tombstone`,[target.operationId,target.receiptId]) + assert.deepEqual(state,{live:true,claim:false,archive:false,tombstone:false}) + }) + const originalQuery = Client.prototype.query, admissionOrder:string[] = [] + try { + Client.prototype.query = function(this:InstanceType,...args:unknown[]) { + const text = (args[0] as {text?:string})?.text + if (text === "SELECT pg_advisory_xact_lock_shared($1::bigint)") admissionOrder.push("common") + if (text === "SELECT pg_try_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0)) AS locked") admissionOrder.push("storage") + return Reflect.apply(originalQuery,this,args) + } as typeof Client.prototype.query + assert.deepEqual(await retireCompletedServerProbeOperation(target), { retired:true,automaticReplay:false }) + } finally { Client.prototype.query = originalQuery } + assert.deepEqual(admissionOrder,["common","storage","common","storage"]) + } else assert.deepEqual(await retireCompletedServerProbeOperation(target), { retired:true,automaticReplay:false }) const proof = (await pool.query(`SELECT NOT EXISTS(SELECT 1 FROM mcp_operations WHERE id=$1) AS op, NOT EXISTS(SELECT 1 FROM mcp_plans WHERE id=$2) AS plan, @@ -184,17 +272,39 @@ try { await assert.rejects(invokeMcpTool(principal,"mm_server_probe_receipt_get",request)) assert.equal((await pool.query("SELECT reserved_bytes::int AS bytes FROM server_probe_storage_usage WHERE scope='global'")).rows[0].bytes,32768) } - if (mode === "retained") await tickMcpWorkers() - else assert.deepEqual(await collectExpiredRetiredServerProbe(target), {collected:true,automaticReplay:false}) - const collected = (await pool.query(`SELECT + const gcState = async (sql:Pick) => (await sql.query(`SELECT (SELECT count(*)::int FROM server_probe_retired_sources WHERE receipt_id=$1) AS archive, (SELECT count(*)::int FROM server_probe_storage_admissions WHERE receipt_id=$1) AS admission, (SELECT count(*)::int FROM server_probe_executions WHERE receipt_id=$1) AS anchor, (SELECT count(*)::int FROM server_probe_execution_stages WHERE receipt_id=$1) AS stages, (SELECT count(*)::int FROM server_probe_execution_payloads WHERE receipt_id=$1) AS payload, (SELECT reserved_bytes::int FROM server_probe_storage_usage WHERE scope='global') AS quota, - (SELECT count(*)::int FROM server_probe_storage_usage WHERE scope=$2) AS owner_counter`,[a.receipt_id,"owner:"+actor.id])).rows[0] - assert.deepEqual(collected,{archive:0,admission:0,anchor:0,stages:0,payload:0,quota:0,owner_counter:0}) + (SELECT count(*)::int FROM server_probe_storage_usage WHERE scope=$2) AS owner_counter, + (SELECT to_jsonb(t) FROM mcp_operation_replay_tombstones t WHERE t.operation_id=$3 AND t.connection_id=$4) AS tombstone, + (SELECT to_jsonb(c) FROM server_probe_retirement_claims c WHERE c.receipt_id=$1 AND c.operation_id=$3 AND c.connection_id=$4) AS claim`, + [a.receipt_id,"owner:"+actor.id,target.operationId,target.connectionId])).rows[0] + const beforeGc = await gcState(pool) + assert(beforeGc.tombstone && beforeGc.claim) + if (mode === "purged") { + await assertWaitsBeforeSecondary(() => collectExpiredRetiredServerProbe(target),async exclusive => { + assert.equal((await exclusive.query("SELECT id FROM public.mcp_settings WHERE id=1 FOR UPDATE NOWAIT")).rows.length,1) + const locked = (await exclusive.query(`SELECT r.proof FROM public.server_probe_retired_sources r + JOIN public.mcp_operation_replay_tombstones t ON t.operation_id=r.operation_id + JOIN public.mcp_connections c ON c.id=t.connection_id + JOIN public.server_probe_storage_admissions a ON a.receipt_id=r.receipt_id AND a.owner_id=c.owner_id + JOIN public.server_probe_executions j ON j.receipt_id=r.receipt_id + WHERE r.receipt_id=$1 AND r.operation_id=$2 AND c.id=$3 FOR UPDATE OF c,r,t,a,j NOWAIT`, + [target.receiptId,target.operationId,target.connectionId])).rows + assert.equal(locked.length,1) + await exclusive.query(`SELECT set_config('mm.probe_storage_gc','on',true),set_config('mm.probe_retired_gc_receipt',$1,true), + set_config('mm.probe_retired_gc_proof',$2,true)`,[target.receiptId,locked[0].proof]) + assert.equal((await exclusive.query("SELECT public.server_probe_storage_gc_allowed($1) AS allowed",[target.receiptId])).rows[0].allowed,true) + },async exclusive => assert.deepEqual(await gcState(exclusive),beforeGc)) + } + if (mode === "retained") await tickMcpWorkers() + else assert.deepEqual(await collectExpiredRetiredServerProbe(target), {collected:true,automaticReplay:false}) + assert.deepEqual(await gcState(pool),{archive:0,admission:0,anchor:0,stages:0,payload:0,quota:0,owner_counter:0, + tombstone:beforeGc.tombstone,claim:beforeGc.claim}) await assert.rejects(collectExpiredRetiredServerProbe(target)) await assert.rejects(invokeMcpTool(principal,"mm_server_probe_receipt_get",request)) await assert.rejects(applyPlan(principal,o.plan_id,key),error=>error instanceof McpError&&error.code==="OPERATION_HISTORY_EXPIRED") diff --git a/backend/src/services/server-probe-operation-retirement.ts b/backend/src/services/server-probe-operation-retirement.ts index 17eb6d6f..19953f7c 100644 --- a/backend/src/services/server-probe-operation-retirement.ts +++ b/backend/src/services/server-probe-operation-retirement.ts @@ -8,6 +8,7 @@ import { findRetiredServerProbeHistory } from "./server-probe-retired-history.js import { matchesServerProbeRetiredTerminal, openServerProbeRetiredSource, projectServerProbeRetiredSource, ServerProbeRetirementSourceRefused } from "./server-probe-retired-source.js" import { connectProbeStorage } from "./server-probe-storage-connection.js" import { sharedDatabaseGate } from "./shared-database-gate.js" +import { admitPartitionWriter } from "./partition-write-transaction.js" const targetSchema = z.object({ receiptId: z.string().uuid(), operationId: z.string().uuid(), connectionId: z.string().uuid() }).strict() type Target = z.infer @@ -69,12 +70,17 @@ const CLOSED_SOURCE = `SELECT ${ORIGINAL},f.source,f.source_proof,f.idempotency_ AND NOT EXISTS(SELECT 1 FROM public.server_probe_retired_sources WHERE receipt_id=$1 OR operation_id=o.id) ${ORIGINAL_ELIGIBLE} FOR UPDATE OF f,o,p,s,k NOWAIT` -async function transaction(deadlineAt: number, work: (sql: ProbeStorageSql) => Promise): Promise { +async function transaction(deadlineAt: number, work: (sql: ProbeStorageSql) => Promise, mode: "default" | "fenced-write" = "default"): Promise { const sql = await connectProbeStorage({ deadlineAt }) let committing = false, committed = false let timer: ReturnType | undefined try { await sql.query("BEGIN") + if (mode === "fenced-write") { + // There is no mutation to reconcile if the common writer fence refuses. + try { await admitPartitionWriter(sql, { deadlineAt: Math.min(deadlineAt, Date.now()+1000) }) } + catch { throw new ProbeStorageRefused() } + } await sql.query("SELECT set_config('TimeZone','UTC',true),set_config('statement_timeout','5000ms',true),set_config('lock_timeout','1000ms',true)") // The same order as admission, publication, cancellation and payload GC. requireThat((await sql.query("SELECT pg_try_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0)) AS locked")).rows[0]?.locked === true) @@ -148,7 +154,7 @@ function captureTerminalProof(target: Target, deadlineAt: number, expectedFinish [target.operationId,target.receiptId,JSON.stringify(signed.source),signed.proof,digest,proof])).rows requireThat(saved.length === 1 && saved[0].receipt_id === target.receiptId) return { captured:true as const, automaticReplay:false as const } - })) + }, "fenced-write")) } /** Captures evidence only while the normal retention owner can still read the original. */ export function captureServerProbeTerminalProof(raw: Target, deadlineAt = Date.now()+20000) { @@ -193,7 +199,7 @@ function retireServerProbeOperation(raw: Target, kind: "retained" | "expired", d [receiptId, operationId, signed.source.planId, connectionId, signed.proof, token]) requireThat(result.rows.length === 1 && result.rows[0].receipt_id === receiptId) return { proof: signed.proof, planId: signed.source.planId, digest } - }) + }, "fenced-write") return transaction(deadlineAt, async sql => { const { signed, key } = await read(sql, target), { planId, proof, digest } = claim requireThat(signed.proof === proof && signed.source.planId === planId && operationIdempotencyDigest(connectionId, key) === digest) @@ -229,7 +235,7 @@ function retireServerProbeOperation(raw: Target, kind: "retained" | "expired", d requireThat(observed && !observed.payloadPurged && fingerprint(observed.source) === fingerprint(signed.source)) } return { retired: true as const, automaticReplay: false as const } - }) + }, "fenced-write") }) } export function retireCompletedServerProbeOperation(raw: Target) { @@ -253,7 +259,7 @@ export function collectExpiredRetiredServerProbe(raw: Target, deadlineAt = Date. JOIN public.mcp_connections c ON c.id=t.connection_id JOIN public.server_probe_storage_admissions a ON a.receipt_id=r.receipt_id AND a.owner_id=c.owner_id JOIN public.server_probe_executions j ON j.receipt_id=r.receipt_id - WHERE r.receipt_id=$1 AND r.operation_id=$2 AND c.id=$3 FOR UPDATE OF c,r,t,a,j NOWAIT`, + WHERE r.receipt_id=$1 AND r.operation_id=$2 AND c.id=$3 FOR UPDATE OF c,r,a,j NOWAIT`, [receiptId, operationId, connectionId])).rows const identity = z.object({ owner_id: z.string().min(1).max(200), proof: z.string().regex(/^[a-f0-9]{64}$/), server_ids: z.array(z.string().regex(/^[1-9]\d{0,15}$/).transform(Number).pipe(z.number().int().positive().safe())).min(1).max(2) }).strict().parse(pinned[0]) @@ -292,7 +298,7 @@ export function collectExpiredRetiredServerProbe(raw: Target, deadlineAt = Date. [receiptId, operationId, connectionId])).rows[0] requireThat(state?.archive_gone === true && state.replay_retained === true && state.claim_retained === true) return { collected: true as const, automaticReplay: false as const } - })) + }, "fenced-write")) } const RETIRE_CANDIDATES = `SELECT COALESCE(a.receipt_id,f.receipt_id) AS receipt_id,o.id AS operation_id,o.connection_id, diff --git a/backend/src/services/server-probe-retention.test.ts b/backend/src/services/server-probe-retention.test.ts index 1d37d43c..050b408c 100644 --- a/backend/src/services/server-probe-retention.test.ts +++ b/backend/src/services/server-probe-retention.test.ts @@ -1,16 +1,16 @@ import assert from "node:assert/strict" import { test } from "node:test" import { createServerProbeRetentionForTests } from "./server-probe-retention.js" -import type { ProbeStorageConnection } from "./server-probe-journal-maintenance.js" +import { ProbeStorageUnknown, type ProbeStorageConnection } from "./server-probe-journal-maintenance.js" import { sharedDatabaseGate } from "./shared-database-gate.js" function fixture(owner = false) { const commands: string[] = [], releases: boolean[] = [] - let settingsReads = 0, drift = false, failCommit = false, checkouts = 0 + let settingsReads = 0, driftAfter = Infinity, failCommit = false, checkouts = 0 const connection: ProbeStorageConnection = { async query(text) { commands.push(text) - if (text.includes("probe-retention:settings")) return { rows: [{ requests_retention_days: drift && ++settingsReads > 5 ? 31 : 30 }] } + if (text.includes("probe-retention:settings")) return { rows: [{ requests_retention_days: ++settingsReads > driftAfter ? 31 : 30 }] } if (text.includes("probe-retention:owners")) return { rows: owner ? [{ owner_id: "owned-retention" }] : [] } if (text.includes("probe-storage:counters")) return { rows: [{ scope: "global" }, { scope: "owner:owned-retention" }] } if (text.includes("probe-storage:gc-candidates")) return { rows: [] } @@ -21,7 +21,7 @@ function fixture(owner = false) { const run = createServerProbeRetentionForTests(async options => { checkouts++; assert(options.deadlineAt <= Date.now() + 20000); return connection }) - return { run, commands, releases, checkouts: () => checkouts, drift: () => { drift = true }, failCommit: () => { failCommit = true } } + return { run, commands, releases, checkouts: () => checkouts, drift: (after = 5) => { driftAfter = after }, failCommit: () => { failCommit = true } } } test("empty retention uses one bounded backend and no mutation", async () => { const f = fixture(); assert.deepEqual(await f.run(), { skipped: false, owners: 0, collected: 0 }) @@ -34,6 +34,11 @@ test("selected owner GC reuses one physical backend for policy fences and transa assert.equal(f.checkouts(), 1); assert.deepEqual(f.releases, [false]) assert.equal(f.commands.filter(text => text === "BEGIN").length, 1) assert.equal(f.commands.filter(text => text === "COMMIT").length, 1) + const begin = f.commands.indexOf("BEGIN") + const writer = f.commands.indexOf("SELECT pg_advisory_xact_lock_shared($1::bigint)") + const storage = f.commands.findIndex(text => text.includes("probe-storage:lock")) + const recheck = f.commands.findIndex((text, index) => index > begin && text.includes("probe-retention:settings")) + assert(begin < writer && writer < recheck && recheck < storage) assert(!f.commands.some(text => /DELETE FROM|\/tool\//.test(text))) }) test("retention drift rolls back without committing cleanup", async () => { @@ -41,6 +46,13 @@ test("retention drift rolls back without committing cleanup", async () => { assert(!f.commands.includes("COMMIT")); assert(f.commands.includes("ROLLBACK")) assert.equal(f.releases.length, 1) }) +test("policy drift on the first in-transaction recheck still destroys the checkout before storage admission", async () => { + const f = fixture(true); f.drift(3) + await assert.rejects(f.run(), ProbeStorageUnknown) + assert(f.commands.includes("SELECT pg_advisory_xact_lock_shared($1::bigint)")) + assert(!f.commands.some(text => /probe-storage:lock|probe-storage:owner-counter|COMMIT|ROLLBACK/.test(text))) + assert.deepEqual(f.releases, [true]) +}) test("unknown commit destroys once and does not repeat the maintenance command", async () => { const f = fixture(true); f.failCommit(); await assert.rejects(f.run()) assert.equal(f.commands.filter(text => text === "COMMIT").length, 1) diff --git a/backend/src/services/server-probe-storage-journal-adapter.test.ts b/backend/src/services/server-probe-storage-journal-adapter.test.ts index 7b47530b..7bd36bf8 100644 --- a/backend/src/services/server-probe-storage-journal-adapter.test.ts +++ b/backend/src/services/server-probe-storage-journal-adapter.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { createHash, randomUUID } from "node:crypto" import { test } from "node:test" +import { PARTITION_WRITER_ADVISORY_KEY } from "./partition-write-transaction.js" import { createSqlServerProbeJournal, type ServerProbeJournalContext } from "./server-probe-journal.js" import { createServerProbeStorageJournalAdapter, ProbePayloadExpired, ProbePayloadUnavailable } from "./server-probe-storage-journal-adapter.js" import { ProbeStorageUnknown, type ProbeStorageConnection, type ProbeStoragePolicy, type ProbeStorageOptions } from "./server-probe-journal-maintenance.js" @@ -20,11 +21,18 @@ function fixture() { const binding: ServerProbeJournalBinding = { receiptId: randomUUID(), bindingProof: "a".repeat(64), ownerId: context.identity.ownerId, transport: "rest", requestId: "accepted-host-request", issuedAt: 1000, serverIds: [1], tool: "ping" } const connect = async (options: ProbeStorageOptions): Promise => { budgets.push(options) - const connection = ++nextConnection; let local: typeof state | undefined, unlock: (() => void) | undefined, mutation = "" + const connection = ++nextConnection; let local: typeof state | undefined, unlock: (() => void) | undefined, mutation = "", writerAdmitted = false return { release(destroy) { unsafe.push(!!destroy); unlock?.() }, async query(text, values = []) { const tag = text.match(/\/\* (probe-[\w-]+:[\w-]+) \*\//)?.[1] ?? text; events.push({ connection, tag }) const result = (rows: Row[] = []) => { if (revokeAt === tag) allowed = false; if (expireAt === tag) now += 10001; return { rows: structuredClone(rows) } } if (text === "BEGIN" || text.startsWith("SET LOCAL")) return result() + if (text === "SELECT pg_advisory_xact_lock_shared($1::bigint)") { + assert(!local && !writerAdmitted) + assert.deepEqual(values, [PARTITION_WRITER_ADVISORY_KEY]) + writerAdmitted = true + return result() + } + if (/^\s*(?:\/\*.*?\*\/\s*)?(?:INSERT|UPDATE|DELETE)\b/.test(text)) assert(writerAdmitted) if (tag === "probe-storage:lock") { const prior = queued; queued = new Promise(resolve => { unlock = resolve }); await prior; local = structuredClone(state); return result() } assert(local, "all journal SQL uses storage-owned transaction and global storage lock") if (tag === "probe-journal:lock") return result() diff --git a/backend/src/services/server-probe-storage-journal-adapter.ts b/backend/src/services/server-probe-storage-journal-adapter.ts index 6e10e681..3cd4d155 100644 --- a/backend/src/services/server-probe-storage-journal-adapter.ts +++ b/backend/src/services/server-probe-storage-journal-adapter.ts @@ -2,6 +2,7 @@ * registration, purge or metadata GC. Requires the separate v2 storage schema. */ import { createHash } from "node:crypto" import { isDeepStrictEqual } from "node:util" +import { admitPartitionWriter } from "./partition-write-transaction.js" import { z } from "zod" import { createProbeJournalStorage, ProbeStorageRefused, ProbeStorageUnknown, validateProbeStoragePolicy, PROBE_PAYLOAD_MAX_BYTES, type ProbeStorageConnection, type ProbeStorageOptions, type ProbeStoragePolicy, type ProbeStorageScope, type ProbeStorageSql } from "./server-probe-journal-maintenance.js" @@ -92,13 +93,25 @@ export function createServerProbeStorageJournalAdapter(rawContext: ServerProbeJo let connection: ProbeStorageConnection try { connection = await connect(current) } catch { throw new ProbeStorageUnknown() } let began = false, commitSent = false, committed = false, destroy = false + const query: ProbeStorageSql["query"] = (text, values) => + connection.query(text, values, { signal: current.signal, timeoutMs: Math.max(1, Math.min(10000, current.deadlineAt - now())) }) const sql: ProbeStorageSql = { query: async (text, values) => { await fence() - const result = await connection.query(text, values, { signal: current.signal, timeoutMs: Math.max(1, Math.min(10000, current.deadlineAt - now())) }) + const result = await query(text, values) await fence(); return result } } try { - await sql.query("BEGIN"); began = true + if (operation === "read") { + await sql.query("BEGIN"); began = true + } else { + await fence() + await query("BEGIN"); began = true + try { + await admitPartitionWriter({ query }, { signal: current.signal, deadlineAt: Date.now() + Math.min(current.deadlineAt - now(), 1000) }) + } catch { throw new ProbeStorageRefused() } + await query("SET LOCAL lock_timeout TO DEFAULT") + await fence() + } await sql.query(`SET LOCAL statement_timeout='${Math.max(1, Math.min(10000, current.deadlineAt - now()))}ms'`) await sql.query("/* probe-storage:lock */ SELECT pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0))") // Existing committed admission is mandatory even after revocation. This diff --git a/backend/src/services/server-probe-storage-journal.sql.test.ts b/backend/src/services/server-probe-storage-journal.sql.test.ts index f73cbc87..9d887c29 100644 --- a/backend/src/services/server-probe-storage-journal.sql.test.ts +++ b/backend/src/services/server-probe-storage-journal.sql.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" -import { basename, dirname, resolve } from "node:path" -import { readFile } from "node:fs/promises" +import { generateKeyPairSync, randomUUID } from "node:crypto" +import { basename, dirname, join, resolve } from "node:path" +import { mkdtemp, readFile, realpath, writeFile } from "node:fs/promises" assert.equal(process.env.MMAPP_OWN_PROBE_STORAGE_JOURNAL, "true") const databaseUrl = new URL(process.env.DATABASE_URL ?? "") assert.equal(databaseUrl.hostname, "127.0.0.1"); assert.equal(databaseUrl.pathname, "/postgres") @@ -71,7 +71,18 @@ try { await (await fixture("snapshot-concurrent-owner", Date.now(), true)).complete() }) console.log("Owned authenticated legacy snapshot:", JSON.stringify(legacySnapshot)) - const inspectionHost = await (await import("./server-probe-legacy-inspection.sql-helper.js")).acceptLegacyProbeInspectionSql() + const { provisionDatabaseSourceBindingOffline } = await import("../../scripts/database-source-binding-operator.js") + const fixtureRoot = await mkdtemp(join(await realpath(dirname(resolve(process.env.MCP_STATE_DIR!))), "source-binding-")) + const privateKeyFile = join(fixtureRoot, "issuer-private.pem") + const { privateKey } = generateKeyPairSync("ed25519") + await writeFile(privateKeyFile, privateKey.export({ format: "pem", type: "pkcs8" }), { mode: 0o600, flag: "wx" }) + const installationId = randomUUID(), sourceIdentityId = randomUUID() + const inspectionHost = await (await import("./server-probe-legacy-inspection.sql-helper.js")).acceptLegacyProbeInspectionSql(async source => { + const generation = randomUUID() + const issued = await provisionDatabaseSourceBindingOffline({ privateKeyFile, outputDirectory: join(fixtureRoot, generation), + request: { installationId, sourceIdentityId, generation, source } }) + return issued.deploymentPin + }) console.log("Owned operator legacy inspection:", JSON.stringify(inspectionHost)) const sourceObservation = await (await import("../db/database-source-observation.sql-helper.js")).acceptDatabaseSourceObservationSql() console.log("Owned source database observation:", JSON.stringify(sourceObservation)) @@ -240,6 +251,15 @@ try { console.log("Owned cancellation source prerequisites:", JSON.stringify(sourcePrerequisites)) console.log("Owned storage source mapping:", JSON.stringify(await (await import("./server-probe-storage-source.sql-helper.js")).acceptProbeStorageSourceSql(serverId))) console.log("Owned bounded storage connection:", JSON.stringify(await (await import("./server-probe-storage-connection.sql-helper.js")).acceptProbeStorageConnectionSql())) + // Only the current MCP runtime subcase needs the replay fence. Keep the + // earlier legacy inspection and conversion fixtures on their historical schema. + const { analyzeMigration, MIGRATION_MANIFEST } = await import("../db/migration-manifest.js") + const replayPin = MIGRATION_MANIFEST.find(pin => pin.file === "0045_mcp_operation_replay_tombstones.sql") + assert(replayPin) + const replayMigration = await readFile(new URL("../../drizzle/" + replayPin.file, import.meta.url)) + analyzeMigration(replayPin.file, replayMigration, replayPin) + const runtimeFixtureClient = await pool.connect() + try { await runtimeFixtureClient.query(replayMigration.toString("utf8")) } finally { runtimeFixtureClient.release(true) } console.log("Owned storage probe runtime host:", JSON.stringify(await (await import("../modules/mcp/server-probe-storage-host.sql-helper.js")).acceptServerProbeStorageHostSql())) console.log("Owned storage REST host:", JSON.stringify(await (await import("./server-probe-storage-rest.sql-helper.js")).acceptServerProbeStorageRestSql())) console.log("Owned bounded metadata cancellation:", JSON.stringify(await (await import("./server-probe-storage-metadata.sql-helper.js")).acceptProbeStorageMetadataSql())) diff --git a/backend/src/services/vrf-production-bridge-delete.test.ts b/backend/src/services/vrf-production-bridge-delete.test.ts new file mode 100644 index 00000000..f6d3ec60 --- /dev/null +++ b/backend/src/services/vrf-production-bridge-delete.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { createHmac } from "node:crypto" +import { sealVrfProductionAdmission, VRF_PRODUCTION_REQUIRED_CATALOGS, type VrfProductionSnapshot, type VrfProductionRow as Row } from "./vrf-production-admission.js" +import { classifyVrfProductionTransition, sealVrfProductionTransitionCheckpoint, openVrfProductionTransitionCheckpoint, assertVrfProductionHistoryProvenance } from "./vrf-production-transition.js" + +const fixture = JSON.parse(await readFile(new URL("./fixtures/vrf-bridge-delete-ll-v33.json", import.meta.url), "utf8")) +const secret = Buffer.alloc(32, 9), now = 1000, sourceProof = "a".repeat(64) +const binding = { serverId: 2, sourceProof, actorId: "human", planId: "bridge-delete-synthetic-graph" } +const name = fixture.before.bridge[0].name, request = { action: "delete" as const, id: "*1", fields: {} } +let cases = 0 +function snapshot(which: "before" | "after"): VrfProductionSnapshot { + const catalogs: Record = Object.fromEntries(VRF_PRODUCTION_REQUIRED_CATALOGS.map(key => [key, []])) + Object.assign(catalogs, structuredClone(fixture[which])) + // These surroundings and single selected member are reconstructed. Captured + // bridge/address/route/virtual shapes above are not a full native raw17 pair. + for (const vrf of catalogs.vrf) vrf.interfaces = name + catalogs.vrf.push({ ".id": "*0", name: "main", interfaces: "all", disabled: "false", builtin: "true" }) + catalogs.routing_table.unshift({ ".id": "*0", name: "main", fib: "true", dynamic: "true" }) + catalogs.ip_settings = [{}]; catalogs.ipv6_settings = [{}] + return { projection: "raw-native-v1", serverId: 2, sourceProof, catalogs } +} +const before = snapshot("before"), after = snapshot("after") +function classify(b = before, a = after, at = now) { + const issued = sealVrfProductionAdmission({ snapshot: b, binding, request, secret, now }) + return classifyVrfProductionTransition({ before: b, after: a, binding, request, secret, now: at, sealed: issued.sealed }) +} +const result = classify() +assert.equal(result.status, "explained-native-transition"); assert.equal(result.forwardingVerified, false); cases++ +const scope = { serverId: 2, sourceProof, actorId: binding.actorId, nativeId: "*1" } +const sealed = sealVrfProductionTransitionCheckpoint(result.checkpoint, scope, secret) +const durable = JSON.parse(Buffer.from(sealed.split(".")[0], "base64url").toString()) +assert.deepEqual(durable.history.absentLl, []); assert.deepEqual(durable.history.provenance.missingMembers, []) +assertVrfProductionHistoryProvenance(openVrfProductionTransitionCheckpoint(sealed, scope, secret), after); cases++ +// Compatible v1 EUI64 history also has no invented revival debt. It retains the +// original proof kind/domain and is not silently upgraded when opened. +durable.version = 1; delete durable.history.provenance.greMembers +const payload = Buffer.from(JSON.stringify(durable)).toString("base64url") +const signature = createHmac("sha256", secret).update("mmapp-vrf-production-transition-history-v1\0").update(payload).digest("hex") +const legacy = openVrfProductionTransitionCheckpoint(payload + "." + signature, scope, secret) +assertVrfProductionHistoryProvenance(legacy, after); cases++ +const inv = (s: VrfProductionSnapshot) => s.catalogs.interface_inventory.find(row => row.name === name)! +const ll = (s: VrfProductionSnapshot) => s.catalogs.ipv6_address[0] +const refuse = (mutate: (b: VrfProductionSnapshot, a: VrfProductionSnapshot) => void) => { + const b = structuredClone(before), a = structuredClone(after); mutate(b, a) + assert.throws(() => classify(b, a), /VRF production/); cases++ +} +for (const mutate of [ + (_b, a) => { inv(a)["mac-address"] = "82:97:C3:74:4C:A6" }, + (_b, a) => { inv(a)[".id"] = "*ffff" }, + (_b, a) => { inv(a).disabled = "true" }, + (_b, a) => { inv(a).running = "false" }, + (_b, a) => { inv(a)["link-downs"] = "1" }, + (_b, a) => { a.catalogs.bridge[0].opaque = "configuration drift" }, + (b, a) => { for (const s of [b, a]) s.catalogs.bridge_port.push({ ".id": "*fe1", bridge: name, interface: "ether-root" }) }, + (b, a) => { for (const s of [b, a]) s.catalogs.bridge_vlan.push({ ".id": "*fe1", bridge: name, "vlan-ids": "1" }) }, + (b, _a) => { ll(b).dynamic = "false" }, + (b, _a) => { ll(b)["auto-link-local"] = "false" }, + (b, _a) => { ll(b)["link-local"] = "false" }, + (b, _a) => { ll(b).invalid = "true" }, + (b, _a) => { ll(b).disabled = "true" }, + (b, _a) => { ll(b)["actual-interface"] = "foreign-parent" }, + (b, _a) => { ll(b).address = "fe80::1/64" }, + (b, _a) => { ll(b).address = "fe80::8097:c3ff:fe74:4ca5/128" }, + (b, _a) => { b.catalogs.ipv6_address.push({ ...ll(b), ".id": "*feed" }) }, + (_b, a) => { a.catalogs.ipv6_route = structuredClone(before.catalogs.ipv6_route) }, + (_b, a) => { a.catalogs.ipv6_route.push({ ...before.catalogs.ipv6_route[0], ".id": "*ffff", protocol: "foreign" }) }, + (_b, a) => { a.catalogs.ipv4_address = [] }, + (_b, a) => { a.catalogs.ipv4_address[0][".id"] = "*ffff" }, + (_b, a) => { a.catalogs.ip_filter.push({ ".id": "*ffff", action: "drop" }) }, + (_b, a) => { a.sourceProof = "b".repeat(64) }, +] as Array<(b: VrfProductionSnapshot, a: VrfProductionSnapshot) => void>) refuse(mutate) +// A real MAC/EUI64 on another concrete type is insufficient for this DELETE +// exception. Existing physical/VLAN/GRE behavior must keep refusing absence. +for (const [type, resource] of [["ether", "ethernet"], ["vlan", "vlan"], ["gre-tunnel", "gre"]]) refuse((b, a) => { + for (const s of [b, a]) { inv(s).type = type; s.catalogs[resource] = [{ ...s.catalogs.bridge[0] }]; s.catalogs.bridge = [] } +}) +assert.throws(() => classify(before, after, now + 600001), /VRF production/); cases++ +console.log("Bridge automatic LL DELETE: captured subset, synthetic admission/classifier, checkpoint and strict negatives:", cases) diff --git a/backend/src/services/vrf-production-gre-cycle.test.mjs b/backend/src/services/vrf-production-gre-cycle.test.mjs new file mode 100644 index 00000000..f38ccd99 --- /dev/null +++ b/backend/src/services/vrf-production-gre-cycle.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import {readFile} from 'node:fs/promises' +import {createHmac} from 'node:crypto' +import ts from 'typescript' + +// Pure production modules only: no SQL/HTTP authority adapters are imported. +const cache=new Map() +async function moduleUri(name){ + if(cache.has(name))return cache.get(name) + const loading=(async()=>{ + let code=ts.transpileModule(await readFile(new URL(name,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText + const ast=ts.createSourceFile(name,code,ts.ScriptTarget.ES2022,true) + 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 moduleUri(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) + if(name==='./vrf-production-transition.ts')code+='\nexport { memberInventoryOmissions, same };\n' + return'data:text/javascript;base64,'+Buffer.from(code).toString('base64') + })();cache.set(name,loading);return loading +} +const a=await import(await moduleUri('./vrf-production-admission.ts')),t=await import(await moduleUri('./vrf-production-transition.ts')),p=await import(await moduleUri('./vrf-production-projection.ts')) + +const pair=JSON.parse(await readFile(new URL('./fixtures/vrf-gre-inverse-cycle-v32.json',import.meta.url),'utf8')) +let cases=0 +const gre=pair.before.catalogs.interface_inventory.filter(row=>row.type==='gre-tunnel') +const rows=(before,after,name)=>[before.catalogs.interface_inventory.find(row=>row.name===name),after.catalogs.interface_inventory.find(row=>row.name===name)] +const selectedBoundary=(before,after,name)=>{const [old,current]=rows(before,after,name);const omit=t.memberInventoryOmissions(before,after,name,old,current);t.same('interface_inventory',old,current,omit);return omit} +for(const old of gre){ + const [a,b]=rows(pair.before,pair.after,old.name) + assert.deepEqual(selectedBoundary(pair.before,pair.after,old.name),['vrf','status','link-downs','last-link-up-time','last-link-down-time']);cases++ +} +// Model forward zero→one separately. This is NOT an actual captured forward pair. +const forwardBefore=structuredClone(pair.after),forwardAfter=structuredClone(pair.before),name=gre[0].name +const [fa,fb]=rows(forwardBefore,forwardAfter,name);fa['link-downs']='0';delete fa['last-link-down-time'];fa['last-link-up-time']='2026-11-05 19:00:00';fb['link-downs']='1';fb['last-link-down-time']='2026-11-05 19:42:15';fb['last-link-up-time']='2026-11-05 19:42:15' +assert.equal(selectedBoundary(forwardBefore,forwardAfter,name).length,5);cases++ +for(const mutate of [ + (a,b)=>{delete b.catalogs.interface_inventory.find(r=>r.name===name)['link-downs']}, + (a,b)=>{delete a.catalogs.interface_inventory.find(r=>r.name===name)['last-link-up-time']}, + (a,b)=>{delete b.catalogs.interface_inventory.find(r=>r.name===name)['last-link-down-time']}, + (a,b)=>{delete b.catalogs.interface_inventory.find(r=>r.name===name)['last-link-up-time']}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['link-downs']='0'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name).ifindex='999'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name).opaque='unreviewed'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['link-downs']='3'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['link-downs']='02'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['last-link-down-time']='2026-11-05 19:00:00'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['last-link-up-time']='2026-11-05 20:30:40'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['last-link-down-time']='not-a-clock'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name).running='false'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name).disabled='true'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name).type='bridge'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['.id']='*ffff'}, + (a,b)=>{b.catalogs.interface_inventory.find(r=>r.name===name)['mac-address']='02:00:00:00:00:01'}, + (a,b)=>{b.catalogs.vrf=structuredClone(a.catalogs.vrf);b.catalogs.interface_inventory.find(r=>r.name===name).vrf=a.catalogs.interface_inventory.find(r=>r.name===name).vrf}, + (a,b)=>{b.catalogs.gre[0]['remote-address']='198.18.245.11'}, + (a,b)=>{b.catalogs.gre[0]['local-address']='198.18.245.99'}, + (a,b)=>{b.catalogs.gre[0].keepalive='20s,20'}, + (a,b)=>{b.catalogs.wireguard[0].opaque='changed-root-config'}, + (a,b)=>{b.catalogs.ipv4_address.find(r=>r.interface.includes('-wg')).comment='changed-source-config'}, +]){ + const a=structuredClone(pair.before),b=structuredClone(pair.after);mutate(a,b);const [old,current]=rows(a,b,name) + assert.throws(()=>selectedBoundary(a,b,name),/VRF production transition refused/);cases++ +} +const unchanged=structuredClone(pair.before);for(const current of unchanged.catalogs.interface_inventory.filter(r=>r.type==='gre-tunnel'))assert.deepEqual(t.memberInventoryOmissions(pair.before,unchanged,current.name,rows(pair.before,unchanged,current.name)[0],current),['vrf','status']);cases++ +console.log('Observed inverse GRE cycle and synthetic bounded-rule negatives:',cases) diff --git a/backend/src/services/vrf-production-peer-telemetry.test.mjs b/backend/src/services/vrf-production-peer-telemetry.test.mjs new file mode 100644 index 00000000..61264baf --- /dev/null +++ b/backend/src/services/vrf-production-peer-telemetry.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import {readFile} from 'node:fs/promises' +import {createHmac} from 'node:crypto' +import ts from 'typescript' + +// Pure production modules only: no SQL/HTTP authority adapters are imported. +const cache=new Map() +async function moduleUri(name){ + if(cache.has(name))return cache.get(name) + const loading=(async()=>{ + let code=ts.transpileModule(await readFile(new URL(name,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText + const ast=ts.createSourceFile(name,code,ts.ScriptTarget.ES2022,true) + 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 moduleUri(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'data:text/javascript;base64,'+Buffer.from(code).toString('base64') + })();cache.set(name,loading);return loading +} +const a=await import(await moduleUri('./vrf-production-admission.ts')),t=await import(await moduleUri('./vrf-production-transition.ts')),p=await import(await moduleUri('./vrf-production-projection.ts')) +const fixture=JSON.parse(await readFile(new URL('./fixtures/vrf-projection-v2.json',import.meta.url),'utf8')) +const {before,after:legacyAfter,binding,request,scope,now,acknowledgedId}=fixture,secret=Buffer.alloc(32,fixture.secretFill) +// Preserve the genuine old proof input verbatim; correct only the current +// synthetic no-MAC GRE after-context used by new-transition assertions. +const after=structuredClone(legacyAfter) +for(const row of after.catalogs.ipv6_address) { + const member=after.catalogs.interface_inventory.find(item=>item.name===row.interface) + if(row.dynamic==='true'&&member?.type==='gre-tunnel'&&!Object.hasOwn(member,'mac-address'))row.vrf=member.vrf +} +let cases=0 +const seal=snapshot=>a.sealVrfProductionAdmission({snapshot,binding,request,secret,now}) +const issued=seal(before) +assert.equal(issued.admission.version,3) +const inspect=snapshot=>a.inspectVrfProductionAdmission({snapshot,binding,request,secret,now,sealed:issued.sealed}) +const input={before,after,binding,request,secret,now,sealed:issued.sealed,acknowledgedId} +const classify=next=>t.classifyVrfProductionTransition({...input,after:next}) +const peer=snapshot=>snapshot.catalogs.wireguard_peer[0] +const decode=sealed=>JSON.parse(Buffer.from(sealed.split('.')[0],'base64url').toString()) +inspect(before);const baseline=classify(after);cases++ +for(const field of ['rx','tx','last-handshake'])for(const value of ['tick',301,false]){ + const b=structuredClone(before),next=structuredClone(after);peer(b)[field]=value;peer(next)[field]=value + inspect(b);const result=classify(next) + const cp=t.openVrfProductionTransitionCheckpoint(t.sealVrfProductionTransitionCheckpoint(result.checkpoint,scope,secret),scope,secret) + const later=structuredClone(next);peer(later)[field]='later';t.assertVrfProductionTransitionCheckpoint(cp,later);cases++ +} +const combinedBefore=structuredClone(before),combinedAfter=structuredClone(after) +for(const s of [combinedBefore,combinedAfter])Object.assign(peer(s),{rx:'501',tx:'601','last-handshake':'2s'}) +inspect(combinedBefore);classify(combinedAfter);cases++ +// Exact actual pre-change ticket: valid v2 MAC, now refused before any claim. +const [oldPayload,oldMac]=fixture.admissionV2.split('.') +assert.equal(decode(fixture.admissionV2).version,2) +assert.equal(createHmac('sha256',secret).update('mmapp-vrf-production-admission-v2\0').update(oldPayload).digest('hex'),oldMac) +assert.throws(()=>a.inspectVrfProductionAdmission({snapshot:before,binding,request,secret,now,sealed:fixture.admissionV2}),/ticket signature changed/);cases++ +const falselyResigned=oldPayload+'.'+createHmac('sha256',secret).update('mmapp-vrf-production-admission-v3\0').update(oldPayload).digest('hex') +assert.throws(()=>a.inspectVrfProductionAdmission({snapshot:before,binding,request,secret,now,sealed:falselyResigned}),/unknown ticket schema/);cases++ +const oldCheckpoint=t.openVrfProductionTransitionCheckpoint(fixture.checkpointProofV1,scope,secret) +t.assertVrfProductionTransitionCheckpoint(oldCheckpoint,legacyAfter) +const tickedAfter=structuredClone(after);peer(tickedAfter).rx='101' +const legacyTicked=structuredClone(legacyAfter);peer(legacyTicked).rx='101' +assert.throws(()=>t.assertVrfProductionTransitionCheckpoint(oldCheckpoint,legacyTicked),/checkpoint parent\/configuration\/order drift/) +const oldResealed=t.sealVrfProductionTransitionCheckpoint(oldCheckpoint,scope,secret) +assert.deepEqual(decode(oldResealed),decode(fixture.checkpointProofV1)) +assert.equal(decode(oldResealed).history.snapshot.kind,'configuration-sha256-v1');cases++ +const currentSealed=t.sealVrfProductionTransitionCheckpoint(baseline.checkpoint,scope,secret) +assert.equal(decode(currentSealed).history.snapshot.kind,'configuration-sha256-v2') +const current=t.openVrfProductionTransitionCheckpoint(currentSealed,scope,secret) +t.assertVrfProductionTransitionCheckpoint(current,tickedAfter);cases++ +const unknownProof=decode(currentSealed);unknownProof.history.snapshot.kind='configuration-sha256-v999' +const unknownPayload=Buffer.from(JSON.stringify(unknownProof)).toString('base64url') +const unknownSealed=unknownPayload+'.'+createHmac('sha256',secret).update('mmapp-vrf-production-transition-history-v1\0').update(unknownPayload).digest('hex') +assert.throws(()=>t.openVrfProductionTransitionCheckpoint(unknownSealed,scope,secret),/private checkpoint source\/history/);cases++ +// Old causal provenance may continue only with fresh current approval/ticket; +// the legacy whole-config digest itself remains strict and never upgraded. +const patch={action:'patch',id:scope.nativeId,fields:{comment:'fresh-v3-approved'}} +const patchAfter=structuredClone(tickedAfter);patchAfter.catalogs.vrf.find(row=>row['.id']===scope.nativeId).comment=patch.fields.comment +const fresh=a.sealVrfProductionAdmission({snapshot:tickedAfter,binding,request:patch,secret,now}) +const continuation=t.classifyVrfProductionTransition({before:tickedAfter,after:patchAfter,binding,request:patch,secret,now,sealed:fresh.sealed,previous:oldCheckpoint}) +assert.equal(decode(t.sealVrfProductionTransitionCheckpoint(continuation.checkpoint,scope,secret)).history.snapshot.kind,'configuration-sha256-v2');cases++ + +function refused(mutate){ + const b=structuredClone(before),next=structuredClone(after);mutate(b);mutate(next) + assert.throws(()=>inspect(b),/VRF production/) + assert.throws(()=>classify(next),/VRF production/);cases++ +} +for(const field of ['rx','tx','last-handshake'])for(const value of [null,{value:302},[302]])refused(s=>{peer(s)[field]=value}) +for(const [field,value]of Object.entries({'public-key':'OTHER_PUBLIC_KEY','private-key':'OTHER_PRIVATE_KEY','preshared-key':'OTHER_PSK','endpoint-address':'192.0.2.3','endpoint-port':'55102','current-endpoint-address':'192.0.2.3','current-endpoint-port':'55102','allowed-address':'198.51.100.0/24','persistent-keepalive':'20s',disabled:'true',running:'false',opaque:'changed',unreviewed:'unknown','rx-byte':'999','.id':'*ff32',interface:'other-wg'}))refused(s=>{peer(s)[field]=value}) +refused(s=>{s.catalogs.wireguard[0]['private-key']='OTHER_PRIVATE_KEY'}) +for(const resource of ['wireguard','gre','interface_inventory','bridge'])for(const field of ['rx','tx','last-handshake']){ + refused(s=>{const row=s.catalogs[resource][0]??{'.id':'*ff90'};row[field]='unreviewed';if(!s.catalogs[resource].length)s.catalogs[resource].push(row)}) +} +for(const field of ['rx','tx','last-handshake']){ + assert.equal(p.isVrfProductionTelemetryValue('wireguard_peer',field,NaN),false) + assert.equal(p.isVrfProductionTelemetryValue('wireguard_peer',field,Infinity),false) + assert.equal(p.isVrfProductionTelemetryValue('wireguard_peer',field,null),false) + assert.equal(p.vrfProductionConfigurationV2('wireguard_peer',{[field]:'old'})[field],'old');cases++ +} +const twoBefore=structuredClone(before),twoAfter=structuredClone(after) +for(const s of [twoBefore,twoAfter])s.catalogs.wireguard_peer.push({...peer(s),'.id':'*ff32','public-key':'SECOND_PUBLIC_KEY'}) +const twoIssued=seal(twoBefore);twoAfter.catalogs.wireguard_peer.reverse() +assert.throws(()=>t.classifyVrfProductionTransition({...input,before:twoBefore,after:twoAfter,sealed:twoIssued.sealed}),/foreign wireguard_peer/);cases++ + +// Inspect actual emitted operation-version expressions and the real runtime +// fence, without loading the application or granting a dispatch capability. +async function ast(name){const source=await readFile(new URL(name,import.meta.url),'utf8');return ts.createSourceFile(name,ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText,ts.ScriptTarget.ES2022,true)} +function nodes(root,predicate){const found=[];function visit(node){if(predicate(node))found.push(node);ts.forEachChild(node,visit)}visit(root);return found} +const actionAst=await ast('../modules/mcp/actions.ts') +const actionCall=nodes(actionAst,node=>ts.isCallExpression(node)&&node.expression.getText(actionAst)==='add'&&node.arguments[0]?.getText(actionAst)==='`ros.${resource.id}.${action}`')[0] +const impl=actionCall.arguments.at(-1),version=impl.properties.find(node=>node.name?.getText(actionAst)==='version').initializer.getText(actionAst) +const declarations=['delegated','previousVersion'].map(name=>nodes(actionAst,node=>ts.isVariableDeclaration(node)&&node.name.getText(actionAst)===name)[0].getText(actionAst)).map(text=>'const '+text+';').join('\n') +const nativeVersion=new Function('resource','action',declarations+'return '+version) +assert.deepEqual(['create','update','delete'].map(action=>nativeVersion({id:'vrf'},action)),[5,4,5]) +const aliasAst=await ast('../modules/mcp/routing-controls.ts'),alias=nodes(aliasAst,node=>ts.isCallExpression(node)&&node.expression.getText(aliasAst)==='register'&&node.arguments[0]?.text==='routing.vrf.update')[0] +assert.equal(alias.arguments.at(-1).properties.find(node=>node.name?.getText(aliasAst)==='version').initializer.text,'4') +assert.equal(alias.arguments[4].text,'mm:network:write');cases++ +const runtimeAst=await ast('../modules/mcp/runtime.ts'),execute=runtimeAst.statements.find(node=>ts.isFunctionDeclaration(node)&&node.name?.text==='executeOperationWithLease'),outer=execute.body.statements.find(ts.isTryStatement) +const fence=outer.tryBlock.statements.find(node=>ts.isIfStatement(node)&&node.expression.getText(runtimeAst).startsWith('plan.operation_version !== findActionByOperation')) +assert.ok(fence);assert.ok(fence.poscheck({operation_id:operation,operation_version:old},()=>({operation:{version:current}}),McpError),error=>error.code==='PLAN_EXPIRED') + check({operation_id:operation,operation_version:current},()=>({operation:{version:current}}),McpError);cases++ +} +console.log('VRF peer three-field telemetry, actual legacy tickets/proofs, conservative drift and pre-intent version fences:',cases) diff --git a/backend/src/services/vrf-production-transition.test.ts b/backend/src/services/vrf-production-transition.test.ts index ad4d1ce4..110b5495 100644 --- a/backend/src/services/vrf-production-transition.test.ts +++ b/backend/src/services/vrf-production-transition.test.ts @@ -22,15 +22,15 @@ function inputFor(before: VrfProductionSnapshot, after: VrfProductionSnapshot, r return { before, after, request, binding, secret, now, sealed: issued.sealed, previous, acknowledgedId } } const phases = ["vrf-create", "vrf-rename", "vrf-disable", "vrf-reenable", "vrf-membership-transfer-a-to-b", "vrf-delete-addresses-remain"] -function replay(transform?: (snapshot: VrfProductionSnapshot) => void) { +function replay(transform?: (snapshot: VrfProductionSnapshot) => void, mapPrimaryId = (id: string) => id) { let before = catalog("addresses-before-vrf"), previous: VrfProductionTransitionCheckpoint | undefined transform?.(before) const inputs: VrfProductionTransitionInput[] = [] for (const phase of phases) { const command = report.journal.find((item: { phase: string }) => item.phase === phase), after = catalog(phase) transform?.(after) - 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 input = inputFor(before, after, request, previous, command.assignedId) + const request: VrfProductionRequest = { action: command.method === "PUT" ? "create" : command.method === "DELETE" ? "delete" : "patch", ...(command.id ? { id: mapPrimaryId(command.id) } : {}), fields: command.fields, ...(command.method === "PUT" ? { placeBeforeId: "*0" } : {}) } + const input = inputFor(before, after, request, previous, command.assignedId ? mapPrimaryId(command.assignedId) : undefined) let result try { result = classifyVrfProductionTransition(input) } catch (error) { throw new Error(`phase ${phase}`, { cause: error }) } assert.equal(result.executionAvailable, false); assert.equal(result.forwardingVerified, false) @@ -55,6 +55,39 @@ replay(snapshot => { } }) const refused = (input: VrfProductionTransitionInput) => { assert.throws(() => classifyVrfProductionTransition(input), /VRF production/); cases++ } +// Preserve the captured native lifecycle while rebinding only the primary +// VRF, its table and the generated Linux ifname. The inventory row keeps +// its separate native ID and matching decimal id field. +function replayWithGeneratedIfname(nativeVrfId: string, ifname: string) { + const originalId = inputs[0].acknowledgedId + assert.ok(originalId) + return replay(snapshot => { + for (const resource of ["vrf", "routing_table"]) { + const row = snapshot.catalogs[resource].find(item => item[".id"] === originalId) + if (row) row[".id"] = nativeVrfId + } + const virtual = snapshot.catalogs.interface_inventory.find(row => row.type === "vrf") + if (virtual?.ifname !== undefined) virtual.ifname = ifname + }, id => id === originalId ? nativeVrfId : id) +} +const hexVrf = replayWithGeneratedIfname("*C", "vrfc") +replayWithGeneratedIfname("*A", "vrfa") +replayWithGeneratedIfname("*C", "vrf12") +const withoutHistory = { ...hexVrf[3], previous: undefined } +assert.equal(classifyVrfProductionTransition(withoutHistory).status, "explained-native-transition"); cases++ +const withIfname = (input: VrfProductionTransitionInput, ifname: string) => { + const after = structuredClone(input.after) + after.catalogs.interface_inventory.find(row => row.type === "vrf")!.ifname = ifname + return { ...input, after } +} +for (const ifname of ["vrfa", "vrfC", "vrf0c", "vrf13", "otherc", "vrff"]) { + refused(withIfname(hexVrf[0], ifname)) + refused(withIfname(withoutHistory, ifname)) +} +// Even the valid decimal spelling cannot replace a different authenticated +// name preserved by the preceding disable operation's private checkpoint. +refused(withIfname(hexVrf[3], "vrf12")) +assert.equal(classifyVrfProductionTransition(withIfname(withoutHistory, "vrf12")).status, "explained-native-transition"); cases++ // Every foreign catalog, opaque key and noncompanion is conserved. for (const resource of VRF_PRODUCTION_REQUIRED_CATALOGS) { const input = { ...inputs[1], after: structuredClone(inputs[1].after) } diff --git a/backend/src/services/vrf-production-transition.ts b/backend/src/services/vrf-production-transition.ts index 9ecfaaeb..73a2eb1b 100644 --- a/backend/src/services/vrf-production-transition.ts +++ b/backend/src/services/vrf-production-transition.ts @@ -308,8 +308,15 @@ function memberInventoryOmissions(before: VrfProductionSnapshot, after: VrfProdu 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") +function matchesGeneratedVrfIfname(ifname: unknown, vrfId: string): boolean { + const id = nativeId(vrfId) + // RouterOS can render the primary VRF ID in lowercase hexadecimal. + return ifname === `vrf${id}` || ifname === `vrf${BigInt("0x" + id)}` +} +function activeVirtual(row: Row, vrfId: string): void { + const ifname = row.ifname + const knownIfnameShape = typeof ifname === "string" && (/^vrf\d+$/.test(ifname) || matchesGeneratedVrfIfname(ifname, vrfId)) + 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" && knownIfnameShape && 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") } function inactiveVirtual(row: Row): void { must(row.type === "vrf" && row.vrf === "main" && yes(row.disabled) && row.running === "false" && row.status === "------------------------" && row.mtu === "65536" && row["mac-address"] === "00:00:00:00:00:00" && ["ifname", "ifindex", "flags", "actual-mtu"].every(field => !Object.hasOwn(row, field)), "bounded native inactive virtual shape") @@ -435,18 +442,20 @@ export function classifyVrfProductionTransition(input: VrfProductionTransitionIn if (current) must(b, "missing resulting virtual interface") if (!old && b) { const enabled = current && no(current.disabled) - if (enabled) activeVirtual(b); else inactiveVirtual(b) - must(Object.keys(b).every(field => isVrfProductionTelemetryValue(resource, field, b[field]) || [".id", "caps", "disabled", "flags", "fp-rps-drop", "id", "ifindex", "ifname", "last-link-up-time", "link-downs", "mac-address", "mtu", "name", "status", "type", "vrf"].includes(field)) && b.caps === "" && b["fp-rps-drop"] === "0" && b["link-downs"] === "0" && typeof b.id === "string" && /^\d+$/.test(b.id) && BigInt(b.id) === BigInt("0x" + nativeId(b[".id"])) && (!enabled || b.ifname === `vrf${BigInt("0x" + nativeId(vrfId))}` && after.catalogs.interface_inventory.filter(row => row.ifindex === b.ifindex).length === 1 && after.catalogs.interface_inventory.filter(row => String(row["mac-address"]).toLowerCase() === String(b["mac-address"]).toLowerCase()).length === 1), "new virtual unknown fields/defaults/generated identity") + if (enabled) activeVirtual(b, vrfId); else inactiveVirtual(b) + must(Object.keys(b).every(field => isVrfProductionTelemetryValue(resource, field, b[field]) || [".id", "caps", "disabled", "flags", "fp-rps-drop", "id", "ifindex", "ifname", "last-link-up-time", "link-downs", "mac-address", "mtu", "name", "status", "type", "vrf"].includes(field)) && b.caps === "" && b["fp-rps-drop"] === "0" && b["link-downs"] === "0" && typeof b.id === "string" && /^\d+$/.test(b.id) && BigInt(b.id) === BigInt("0x" + nativeId(b[".id"])) && (!enabled || matchesGeneratedVrfIfname(b.ifname, vrfId) && after.catalogs.interface_inventory.filter(row => row.ifindex === b.ifindex).length === 1 && after.catalogs.interface_inventory.filter(row => String(row["mac-address"]).toLowerCase() === String(b["mac-address"]).toLowerCase()).length === 1), "new virtual unknown fields/defaults/generated identity") if (enabled) active = structuredClone(b) } else if (a && b) { must(a[".id"] === b[".id"], "virtual interface ID changed") if (disable) { - activeVirtual(a) + activeVirtual(a, vrfId) must(yes(b.disabled) && b.status === "------------------------" && b["mac-address"] === "00:00:00:00:00:00" && a["link-downs"] === "0" && b["link-downs"] === "1" && a["last-link-down-time"] === undefined && timestamp(b["last-link-down-time"]) && String(b["last-link-down-time"]) >= String(a["last-link-up-time"]) && b.running === "false" && ["ifname", "ifindex", "flags", "actual-mtu"].every(field => !Object.hasOwn(b, field)), "bounded virtual disable shape") same(resource, a, b, ["name", "disabled", "status", "mac-address", "link-downs", "last-link-down-time", "ifname", "ifindex", "flags"]); active = structuredClone(a) } else if (reenable) { - inactiveVirtual(a); activeVirtual(b) - must(b.ifname === (history?.activeVirtual?.ifname ?? `vrf${BigInt("0x" + nativeId(vrfId))}`) && (a["last-link-down-time"] === undefined || timestamp(a["last-link-down-time"]) && String(b["last-link-up-time"]) >= a["last-link-down-time"]) && after.catalogs.interface_inventory.filter(row => row.ifindex === b.ifindex).length === 1 && after.catalogs.interface_inventory.filter(row => typeof row["mac-address"] === "string" && String(row["mac-address"]).toLowerCase() === String(b["mac-address"]).toLowerCase()).length === 1, "reenable generated identity/chronology") + inactiveVirtual(a); activeVirtual(b, vrfId) + const previousIfname = history?.activeVirtual?.ifname + const sameIfname = previousIfname === undefined ? matchesGeneratedVrfIfname(b.ifname, vrfId) : b.ifname === previousIfname + must(sameIfname && (a["last-link-down-time"] === undefined || timestamp(a["last-link-down-time"]) && String(b["last-link-up-time"]) >= a["last-link-down-time"]) && after.catalogs.interface_inventory.filter(row => row.ifindex === b.ifindex).length === 1 && after.catalogs.interface_inventory.filter(row => typeof row["mac-address"] === "string" && String(row["mac-address"]).toLowerCase() === String(b["mac-address"]).toLowerCase()).length === 1, "reenable generated identity/chronology") same(resource, a, b, ["name", "disabled", "status", "mac-address", "ifname", "ifindex", "flags", "last-link-up-time"]); active = structuredClone(b) } else { same(resource, a, b, ["name"]); active = history?.activeVirtual } } diff --git a/backend/src/services/vrf-production-wireguard-alias.test.ts b/backend/src/services/vrf-production-wireguard-alias.test.ts new file mode 100644 index 00000000..b43c1dcd --- /dev/null +++ b/backend/src/services/vrf-production-wireguard-alias.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { readVrfProductionSnapshot, VRF_PRODUCTION_RAW_PATHS, type ReadVrfProductionInput } from "./vrf-production-read.js" +import { sealVrfProductionAdmission, inspectVrfProductionAdmission, VRF_PRODUCTION_REQUIRED_CATALOGS, type VrfProductionSnapshot } from "./vrf-production-admission.js" +import { vrfProductionDependencyObjects } from "./vrf-production-transition.js" + +const marker = "mcp-route-fea781dcfaab", wg = marker + "-wg", gre = marker + "-gre-a" +const proof = "a".repeat(64), secret = Buffer.alloc(32, 73), now = 100000 +const binding = { serverId: 1, sourceProof: proof, actorId: "fixture-owner", planId: "fixture-vrf-plan" } + +// Reduced configuration from the V23 safe snapshot: GRE -> static outer IPv4 -> +// RouterOS /interface type "wg". Original native IDs and raw type stay intact. +function model(type = "wg") { + const catalogs: VrfProductionSnapshot["catalogs"] = Object.fromEntries(VRF_PRODUCTION_REQUIRED_CATALOGS.map(resource => [resource, []])) + catalogs.ip_settings = [{ "rp-filter": "loose" }] + catalogs.ipv6_settings = [{ "disable-ipv6": "false" }] + catalogs.vrf = [{ ".id": "*0", name: "main", interfaces: "all", builtin: "true", disabled: "false" }] + catalogs.routing_table = [{ ".id": "*0", name: "main", fib: "true" }] + catalogs.interface_inventory = [{ ".id": "*1B", name: wg, type, disabled: "false" }, + { ".id": "*1C", name: gre, type: "gre-tunnel", disabled: "false" }] + catalogs.wireguard = [{ ".id": "*1B", name: wg, "listen-port": "55100", mtu: "1420", disabled: "false", comment: marker }] + catalogs.wireguard_peer = [{ ".id": "*3", interface: wg, "endpoint-address": "192.0.2.7", "endpoint-port": "55101", + "allowed-address": "198.18.245.10/32,198.18.245.14/32", "persistent-keepalive": "10s", disabled: "false", comment: marker }] + catalogs.gre = [{ ".id": "*1C", name: gre, "local-address": "198.18.245.9", "remote-address": "198.18.245.10", + "allow-fast-path": "false", "clamp-tcp-mss": "true", disabled: "false", comment: marker }] + catalogs.ipv4_address = [{ ".id": "*1F", interface: wg, address: "198.18.245.9/30", dynamic: "false", disabled: "false", vrf: "main", comment: marker }, + { ".id": "*21", interface: gre, address: "198.18.245.1/30", dynamic: "false", disabled: "false", vrf: "main", comment: marker }] + catalogs.ipv4_route = [{ ".id": "*30", "dst-address": "198.18.245.0/30", dynamic: "true", connect: "true", "routing-table": "main", gateway: gre, "immediate-gw": gre }] + let enabled = true, sourceProof = proof, revoked = false + let afterGet = (_path: string) => {} + const calls: string[] = [] + const input: ReadVrfProductionInput = { serverId: 1, expectedSourceProof: proof, + request: { action: "create", placeBeforeId: "*0", fields: { name: marker + "-vrf", interfaces: gre, comment: marker } }, + async assertAuthority() { assert(!revoked, "Owner revoked") }, + adapter: { async resolve() { return { serverId: 1, enabled, sourceProof, client: { async get(path: string) { + calls.push(path) + const resource = Object.keys(VRF_PRODUCTION_RAW_PATHS).find(key => VRF_PRODUCTION_RAW_PATHS[key] === path) + assert(resource && Object.hasOwn(catalogs, resource), "Unexpected raw read") + const result = structuredClone(catalogs[resource]) + afterGet(path) + return result as T + } } } } }, + } + return { catalogs, input, calls, revoke() { revoked = true }, disable() { enabled = false }, + changeSource() { sourceProof = "b".repeat(64) }, afterGet(callback: (path: string) => void) { afterGet = callback } } +} + +for (const type of ["wg", "wireguard"]) test(`reads and seals GRE through static outer IPv4 and ${type} parent without changing native identity`, async () => { + const m = model(type) + const snapshot = await readVrfProductionSnapshot(m.input) + assert.equal(snapshot.catalogs.interface_inventory[0].type, type) + assert(m.calls.includes("/interface/wireguard/peers")) + const issued = sealVrfProductionAdmission({ snapshot, request: m.input.request, binding, secret, now }) + const checked = inspectVrfProductionAdmission({ sealed: issued.sealed, snapshot, request: m.input.request, binding, secret, now: now + 1 }) + assert.deepEqual(checked.admission.members[0].parents.map(parent => [parent.id, parent.resource, parent.configurationId]), + [["*1C", "gre", "*1C"], ["*1B", "wireguard", "*1B"]]) + assert.equal(checked.admission.members[0].addresses.length, 2) + assert.deepEqual(vrfProductionDependencyObjects(snapshot, checked.admission).map(object => [object.kind, object.id]), [ + ["gre", "*1C"], ["interface_inventory", "*1C"], ["wireguard", "*1B"], ["interface_inventory", "*1B"], + ["ipv4_address", "*1F"], ["ipv4_address", "*21"], ["ipv4_route", "*30"], + ]) +}) + +// Synthetic provenance and route evidence close the layer that alias-only +// reader/admission tests previously skipped; they do not establish a live cause. +test("alias dependency closure refuses missing static provenance and exact connected route evidence", async () => { + for (const mutation of ["absent-vrf", "absent-dynamic", "missing-route", "immediate-gw-mismatch"]) { + const m = model() + const address = m.catalogs.ipv4_address[1] + if (mutation === "absent-vrf") delete address.vrf + if (mutation === "absent-dynamic") delete address.dynamic + if (mutation === "missing-route") m.catalogs.ipv4_route = [] + if (mutation === "immediate-gw-mismatch") m.catalogs.ipv4_route[0]["immediate-gw"] = wg + const snapshot = await readVrfProductionSnapshot(m.input) + const issued = sealVrfProductionAdmission({ snapshot, request: m.input.request, binding, secret, now }) + assert.throws(() => vrfProductionDependencyObjects(snapshot, issued.admission), + mutation.startsWith("absent-") ? /static address provenance\/context/ : /dependency connected route identity/) + } +}) + +test("unsupported parent types and ambiguous or dynamic tunnel source parents still refuse", async () => { + for (const type of ["wg-unknown", "__proto__", "constructor"]) { + const m = model(type) + await assert.rejects(readVrfProductionSnapshot(m.input), /unsupported\/missing concrete/) + } + for (const mutation of ["duplicate-address", "dynamic-address", "duplicate-inventory", "unresolved-peer"]) { + const m = model() + if (mutation === "duplicate-address") m.catalogs.ipv4_address.push({ ...m.catalogs.ipv4_address[0], ".id": "*99" }) + if (mutation === "dynamic-address") m.catalogs.ipv4_address[0].dynamic = "true" + if (mutation === "duplicate-inventory") m.catalogs.interface_inventory.push({ ...m.catalogs.interface_inventory[0], ".id": "*99" }) + if (mutation === "unresolved-peer") m.catalogs.wireguard_peer[0]["endpoint-address"] = "peer.example.test" + await assert.rejects(readVrfProductionSnapshot(m.input), /VRF production read refused/) + } +}) + +test("authority revocation, disabled source and source drift still stop the aliased parent read", async () => { + for (const change of ["revoke", "disable", "changeSource"] as const) { + const m = model() + m.afterGet(path => { if (path === "/interface/wireguard") m[change]() }) + await assert.rejects(readVrfProductionSnapshot(m.input)) + assert.equal(m.calls.at(-1), "/interface/wireguard") + } +}) + +test("sealed alias admission still refuses stale tickets and peer or root configuration drift", async () => { + const m = model(), snapshot = await readVrfProductionSnapshot(m.input) + const issued = sealVrfProductionAdmission({ snapshot, request: m.input.request, binding, secret, now, ttlMs: 1000 }) + const input = { sealed: issued.sealed, snapshot, request: m.input.request, binding, secret, now: now + 1 } + assert.throws(() => inspectVrfProductionAdmission({ ...input, now: now + 1000 }), /expired\/future ticket/) + for (const change of ["peer-disabled", "peer-allowed", "wg-disabled", "inventory-id", "gre-source"]) { + const changed = structuredClone(snapshot) + if (change === "peer-disabled") changed.catalogs.wireguard_peer[0].disabled = "true" + if (change === "peer-allowed") changed.catalogs.wireguard_peer[0]["allowed-address"] = "0.0.0.0/0" + if (change === "wg-disabled") changed.catalogs.wireguard[0].disabled = "true" + if (change === "inventory-id") changed.catalogs.interface_inventory[0][".id"] = "*99" + if (change === "gre-source") changed.catalogs.gre[0]["local-address"] = "198.18.245.13" + assert.throws(() => inspectVrfProductionAdmission({ ...input, snapshot: changed }), /VRF production admission refused/) + } +}) diff --git a/backend/src/services/vrf-transition-refusal.test.mjs b/backend/src/services/vrf-transition-refusal.test.mjs new file mode 100644 index 00000000..de0a3d3c --- /dev/null +++ b/backend/src/services/vrf-transition-refusal.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import {readFile} from 'node:fs/promises' +import {createHmac} from 'node:crypto' +import ts from 'typescript' + +// Pure production modules only: no SQL/HTTP authority adapters are imported. +const cache=new Map() +async function moduleUri(name){ + if(cache.has(name))return cache.get(name) + const loading=(async()=>{ + let code=ts.transpileModule(await readFile(new URL(name,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText + const ast=ts.createSourceFile(name,code,ts.ScriptTarget.ES2022,true) + 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 moduleUri(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'data:text/javascript;base64,'+Buffer.from(code).toString('base64') + })();cache.set(name,loading);return loading +} +const a=await import(await moduleUri('./vrf-production-admission.ts')),t=await import(await moduleUri('./vrf-production-transition.ts')),p=await import(await moduleUri('./vrf-production-projection.ts')) + +const r=await import(await moduleUri('./vrf-transition-refusal.ts')) +const d=await import(await moduleUri('./vrf-execution-diagnostic.ts')) +const fixture=JSON.parse(await readFile(new URL('./fixtures/vrf-projection-v2.json',import.meta.url),'utf8')) +const {before,binding,request,scope,now,acknowledgedId}=fixture,after=structuredClone(fixture.after),secret=Buffer.alloc(32,fixture.secretFill) +// Correct this current synthetic vector only; preserve genuine legacy fixture. +for(const row of after.catalogs.ipv6_address){const parent=after.catalogs.interface_inventory.find(item=>item.name===row.interface);if(row.dynamic==='true'&&parent?.type==='gre-tunnel'&&!Object.hasOwn(parent,'mac-address'))row.vrf=parent.vrf} +const issued=a.sealVrfProductionAdmission({snapshot:before,binding,request,secret,now}) +const input={before,after,binding,request,secret,now,sealed:issued.sealed,acknowledgedId} +const native=after.catalogs.interface_inventory.find(row=>row.type==='gre-tunnel') +const ll=snapshot=>snapshot.catalogs.ipv6_address.find(row=>row.interface===native.name&&row.dynamic==='true') +const inventory=snapshot=>snapshot.catalogs.interface_inventory.find(row=>row.name===native.name) +const caught=work=>{let result;try{work()}catch(error){result=error}assert.ok(result,'expected refusal');return result} +let cases=0 +assert.equal(t.classifyVrfProductionTransition(input).status,'explained-native-transition');cases++ +for(const [mutate,guard,section]of [ + [s=>{ll(s).vrf='main'},'GRE_LINK_LOCAL_EFFECTIVE_CONTEXT','after-address'], + [s=>{inventory(s).vrf='main'},'MEMBER_NATIVE_VRF_CONTEXT','member-inventory'], + [s=>{inventory(s)['link-downs']='999'},'BOUNDED_GRE_CYCLE_COUNT','member-inventory'], + [s=>{inventory(s)['last-link-down-time']='2026-11-05 19:42:15'},'BOUNDED_GRE_CYCLE_COUNT','member-inventory'], + [s=>{inventory(s).opaque='SECRET_NATIVE_VALUE'},'OPAQUE_CONFIGURATION_DRIFT','member-inventory'], + [s=>{s.catalogs.ip_filter.push({'.id':'*fffa',opaque:'SECRET_NATIVE_VALUE'})},'FOREIGN_CATALOG_CONFIGURATION_ORDER_CHANGED','foreign'], +]){ + const changed=structuredClone(after);mutate(changed) + const error=caught(()=>t.classifyVrfProductionTransition({...input,after:changed})) + assert.equal(Object.getPrototypeOf(error),Error.prototype) + const tag=r.readVrfTransitionRefusal(error);assert.deepEqual(tag,{guard,section}) + const event=d.vrfExecutionDiagnostic('12345678-1234-4123-8123-123456789abc','create','transition-classification',error,true) + assert.deepEqual(event.transition,tag);assert.equal(JSON.stringify(event).includes('SECRET_NATIVE_VALUE'),false) + assert.equal(caught(()=>r.withVrfTransitionSection('virtual',()=>{throw error})),error) + assert.deepEqual(r.readVrfTransitionRefusal(error),tag) + tag.section='virtual';assert.equal(r.readVrfTransitionRefusal(error).section,section);cases++ +} +const fake=Error('VRF production transition refused: GRE link-local effective context') +Object.defineProperty(fake,'message',{get(){throw Error('forbidden message read')}}) +Object.defineProperty(fake,'stack',{get(){throw Error('forbidden stack read')}}) +assert.equal(r.readVrfTransitionRefusal(fake),undefined) +assert.equal(d.vrfExecutionDiagnostic('12345678-1234-4123-8123-123456789abc','create','transition-classification',fake,false).transition,undefined) +assert.equal(caught(()=>r.withVrfTransitionSection('foreign',()=>{throw fake})),fake) +assert.equal(r.readVrfTransitionRefusal(fake),undefined);cases++ +assert.equal(r.readVrfTransitionRefusal(new Proxy(fake,{get(){throw Error('proxy')}})),undefined);cases++ +let coerced=0 +const unowned=Error('unowned') +r.noteVrfTransitionRefusal(unowned,{[Symbol.toPrimitive](){coerced++;return 'opaque configuration drift'}}) +assert.equal(coerced,0);assert.equal(r.readVrfTransitionRefusal(unowned),undefined) +assert.equal(Object.isFrozen(r.VRF_TRANSITION_REASON_CODES),true) +assert.throws(()=>Object.defineProperty(r.VRF_TRANSITION_REASON_CODES,'opaque configuration drift',{get(){throw Error('mapping getter')}})) +cases++ +assert.equal(new Set(Object.values(r.VRF_TRANSITION_REASON_CODES)).size,Object.values(r.VRF_TRANSITION_REASON_CODES).length) +const source=await readFile(new URL('./vrf-production-transition.ts',import.meta.url),'utf8'),ast=ts.createSourceFile('transition.ts',source,ts.ScriptTarget.ES2022,true) +let staticReasons=0,dynamicReasons=0 +function visit(node){if(ts.isCallExpression(node)&&node.expression.getText(ast)==='must'){ + const reason=node.arguments[1] + if(ts.isStringLiteral(reason)){assert.ok(Object.hasOwn(r.VRF_TRANSITION_REASON_CODES,reason.text));staticReasons++} + else{assert.equal(node.arguments[2]?.text,'FOREIGN_CATALOG_CONFIGURATION_ORDER_CHANGED');dynamicReasons++} +}ts.forEachChild(node,visit)}visit(ast) +assert.ok(staticReasons>50);assert.equal(dynamicReasons,1);cases++ +console.log('GRE effective-context, strict chronology, owned original-error tags and section identity cases:',cases) diff --git a/backend/src/services/wireguard-live.ts b/backend/src/services/wireguard-live.ts index 0fc32d33..d5ec4202 100644 --- a/backend/src/services/wireguard-live.ts +++ b/backend/src/services/wireguard-live.ts @@ -347,8 +347,8 @@ export async function listWireGuardPeersForCatalog(serverId: number): Promise<{ }] }) return { peers } - } catch (e) { - return { peers: [], error: e instanceof Error ? e.message : String(e) } + } catch { + return { peers: [], error: "Не удалось получить список пиров WireGuard." } } } diff --git a/backend/src/test/mcp-routing-sources.ts b/backend/src/test/mcp-routing-sources.ts index 85f1356c..2e0945de 100644 --- a/backend/src/test/mcp-routing-sources.ts +++ b/backend/src/test/mcp-routing-sources.ts @@ -54,6 +54,34 @@ export async function verifyRoutingSources(actor: AuthUser) { assert.equal(visible.observations.length, 1); assert.equal(visible.observations[0].sourceVerified, true) assert.ok(visible.observations[0].recordedAt) await assert.rejects(invokeMcpTool(historyPrincipal, "mm_routing_measurements_query", { serverId: 1 })) + const historyFilter = { srcServerId: 101, dstServerId: 102, srcInterface: "gre", dstInterface: "gre", family: "ipv6", table: "main", limit: 1 } + type HistoryPage = { observations: Awaited>; nextCursor: { recordedAt: string; id: string } | null } + const historyPage = (filters: Record = {}) => invokeMcpTool(historyPrincipal, "mm_routing_measurements_query", { ...historyFilter, ...filters }) as Promise + const firstId = latest.id + const tiedId = randomUUID(), olderId = randomUUID(), differentInterfaceId = randomUUID() + await pool.query(`INSERT INTO mcp_routing_observations(id,src_server_id,dst_server_id,src_interface,dst_interface,family,routing_table,context_verified,source,created_at) + SELECT fixture.id,src_server_id,dst_server_id,src_interface,fixture.iface,family,routing_table,false,'fixture',created_at-fixture.lag*interval '1 microsecond' + FROM mcp_routing_observations CROSS JOIN (VALUES ($2::uuid,'gre',0),($3::uuid,'gre',1),($4::uuid,'other',0)) AS fixture(id,iface,lag) + WHERE mcp_routing_observations.id=$1`, [firstId, tiedId, olderId, differentInterfaceId]) + try { + const firstPage = await historyPage() + assert.ok(firstPage.nextCursor) + const secondPage = await historyPage({ cursor: firstPage.nextCursor }) + assert.ok(secondPage.nextCursor) + const thirdPage = await historyPage({ cursor: secondPage.nextCursor }) + assert.equal(firstPage.nextCursor.recordedAt, secondPage.nextCursor.recordedAt) + assert.deepEqual([firstPage.observations[0].id, secondPage.observations[0].id], [firstId, tiedId].sort().reverse()) + assert.equal(thirdPage.observations[0].id, olderId) + assert.equal(thirdPage.nextCursor, null) + assert.equal((await historyPage({ dstInterface: "other" })).observations[0].id, differentInterfaceId) + assert.deepEqual((await historyPage({ srcServerId: 102, dstServerId: 101 })).observations, []) + assert.deepEqual((await historyPage({ table: "other" })).observations, []) + await assert.rejects(historyPage({ dstServerId: 1 })) + await assert.rejects(historyPage({ limit: 101 })) + console.log("✓ Routing measurement history: exact directional filters, scoped endpoints, stable microsecond/UUID pagination and bounded result") + } finally { + await pool.query("DELETE FROM mcp_routing_observations WHERE id=ANY($1::uuid[])", [[tiedId, olderId, differentInterfaceId]]) + } const firstEpoch = await epoch() await pool.query("UPDATE servers SET name='renamed',routing_endpoint_epoch=$1 WHERE id=102", [randomUUID()]) assert.equal(await epoch(), firstEpoch); assert.equal((await read())[0].sourceVerified, true) diff --git a/backend/src/test/mcp-users-bindings.ts b/backend/src/test/mcp-users-bindings.ts index 454e2c5d..0e25caa3 100644 --- a/backend/src/test/mcp-users-bindings.ts +++ b/backend/src/test/mcp-users-bindings.ts @@ -3,13 +3,14 @@ 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 { interfaceCatalogStateSchema, userBindingCreateSchema } from "@mmapp/contracts/users" 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" +import { MAX_MCP_PUBLIC_ARRAY_ITEMS } from "../modules/mcp/security.js" +import { addBinding, getInterfaceCatalogState, listInterfaceCatalog } from "../modules/users/service/users-service.js" /** Owned PostgreSQL and loopback RouterOS fixture; never writes to a real router. */ export async function verifyMcpUserBindings(actor: AuthUser) { @@ -28,14 +29,27 @@ export async function verifyMcpUserBindings(actor: AuthUser) { { name: "ipsec-vpn", type: "ipsec", raw: "ipsec", peerPublicKey: "owned-psk" }, ] const pendingOperations = new Set() + const providerCanary = "internal RouterOS diagnostic " + randomUUID() + const wgPeer = { ".id": "*A", interface: "wg-owned-user", "public-key": wgKey, name: "Owned WG peer", "allowed-address": "192.0.2.7/32", disabled: "false" } + let catalogMode: "normal" | "upstream-error" | "duplicate" | "many-peers" = "normal" 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/, "") + if (catalogMode === "upstream-error" && (path === "/interface/wireguard/peers" || path === "/certificate")) { + res.statusCode = 500 + res.end(JSON.stringify({ detail: providerCanary })) + return + } + const wgRows = catalogMode === "duplicate" ? [wgPeer, { ...wgPeer, ".id": "*D" }] + : catalogMode === "many-peers" ? Array.from({ length: MAX_MCP_PUBLIC_ARRAY_ITEMS + 1 }, (_, i) => { + const key = Buffer.alloc(32) + key.writeUInt16BE(i) + return { ...wgPeer, ".id": "*" + (i + 10).toString(16), "public-key": key.toString("base64") } + }) : [wgPeer] 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 === "/interface/wireguard/peers" ? wgRows : 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)) @@ -57,7 +71,9 @@ export async function verifyMcpUserBindings(actor: AuthUser) { 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 snapshot = fixtures.filter(iface => iface.type !== "ipsec").map(iface => ({ name: iface.name, type: iface.raw, running: true, disabled: false })) + 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(snapshot)]) + const replaceSnapshot = (rows: typeof snapshot) => pool.query("UPDATE server_snapshots SET raw_interfaces=$3::jsonb WHERE server_id=$1 AND polled_at=$2", [serverId, sampledAt.toISOString(), JSON.stringify(rows)]) 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"]) @@ -130,9 +146,48 @@ export async function verifyMcpUserBindings(actor: AuthUser) { 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"]) { + const catalogTools = ["mm_user_interfaces_list", "mm_user_interfaces_state_get"] + for (const tool of catalogTools) { await assert.rejects(invokeMcpTool(narrow, tool, { serverId }), matches("SERVER_DENIED")) } + try { + catalogMode = "upstream-error" + const publicState = interfaceCatalogStateSchema.parse(await getInterfaceCatalogState(serverId)) + assert.deepEqual(publicState.failures.map(failure => failure.resource), ["wireguard", "ipsec"]) + assert.equal(publicState.inventoryComplete, true) + assert.ok(publicState.interfaces.find(iface => iface.name === "wg-owned-user")?.peersError) + assert.ok(publicState.interfaces.find(iface => iface.name === "ipsec-vpn")?.peersError) + assert.ok(!JSON.stringify(publicState).includes(providerCanary)) + const legacy = await listInterfaceCatalog(serverId) + assert.ok(legacy.find(iface => iface.name === "wg-owned-user")?.peersError) + assert.ok(!JSON.stringify(legacy).includes(providerCanary)) + for (const tool of catalogTools) { + const result = await invokeMcpTool(readOnly, tool, { serverId }) + assert.ok(!JSON.stringify(result).includes(providerCanary)) + } + + catalogMode = "duplicate" + assert.equal(interfaceCatalogStateSchema.safeParse(await getInterfaceCatalogState(serverId)).success, false) + for (const tool of catalogTools) { + await assert.rejects(invokeMcpTool(readOnly, tool, { serverId }), matches("STATE_UNAVAILABLE")) + } + + catalogMode = "many-peers" + assert.equal((await listInterfaceCatalog(serverId)).find(iface => iface.name === "wg-owned-user")?.peers?.length, MAX_MCP_PUBLIC_ARRAY_ITEMS + 1) + for (const tool of catalogTools) { + await assert.rejects(invokeMcpTool(readOnly, tool, { serverId }), matches("BUDGET_EXCEEDED")) + } + + catalogMode = "normal" + await replaceSnapshot(Array.from({ length: MAX_MCP_PUBLIC_ARRAY_ITEMS + 1 }, (_, i) => ({ ...snapshot[0], name: `ether-owned-catalog-${i}` }))) + assert.equal(interfaceCatalogStateSchema.parse(await getInterfaceCatalogState(serverId)).inventoryComplete, true) + for (const tool of catalogTools) { + await assert.rejects(invokeMcpTool(readOnly, tool, { serverId }), matches("BUDGET_EXCEEDED")) + } + } finally { + catalogMode = "normal" + await replaceSnapshot(snapshot) + } 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. diff --git a/backend/src/test/routing-controller-baseline-acceptance-main.ts b/backend/src/test/routing-controller-baseline-acceptance-main.ts index 00867dca..2be4d516 100644 --- a/backend/src/test/routing-controller-baseline-acceptance-main.ts +++ b/backend/src/test/routing-controller-baseline-acceptance-main.ts @@ -13,7 +13,7 @@ if (process.env.MMAPP_ROUTING_BASELINE_OWN_SQL !== "true" || url.hostname !== "1 url.pathname !== "/postgres" || !url.port || url.port === "5432" || process.env.NODE_ENV !== "test" || process.env.MCP_STATE_DIR !== resolve(process.cwd(), "state")) throw new Error("Requires its own disposable routing baseline SQL runner") const mode = process.env.MMAPP_ROUTING_BASELINE_MODE -if (mode !== "main-and-forward-rounds" && mode !== "main-only" && mode !== "default-scoped") throw new Error("Unknown owned acceptance mode") +if (mode !== "main-and-forward-rounds" && mode !== "main-only" && mode !== "default-scoped" && mode !== "scoped-bare-readback") throw new Error("Unknown owned acceptance mode") const actor: AuthUser = { id: "targeted-baseline-owner", email: "test@example.invalid", name: "Targeted", apps: ["mm"], permissions: ["mm:settings:admin", "mm:certificates:write", "mm:network:admin", "mm:bgp:admin", "mm:servers:admin", "mm:filters:admin"], expiresAt: Date.now() + 3600000 } let failed = false @@ -21,10 +21,12 @@ try { await applySqlMigrations(pool) await pool.query("INSERT INTO geoip_settings(id,enabled) VALUES(1,false) ON CONFLICT(id) DO UPDATE SET enabled=false") await updateMcpSettings({ enabled: true }, actor) - if (mode !== "default-scoped") await acceptMainBaselineRuntime(actor) - const scoped = mode === "main-only" ? undefined : await acceptScopedRouteCommunitySwitch(actor, mode === "main-and-forward-rounds" ? { forwardRoundsOnly: true } : {}) + const main = mode === "main-only" || mode === "main-and-forward-rounds" + if (main) await acceptMainBaselineRuntime(actor) + const scoped = mode === "main-only" ? undefined : await acceptScopedRouteCommunitySwitch(actor, + mode === "main-and-forward-rounds" ? { forwardRoundsOnly: true } : mode === "scoped-bare-readback" ? { bareReadbackOnly: true } : {}) await writeFile(resolve(process.cwd(), "runtime-results.json"), JSON.stringify({ passed: true, mode, productionApplied: false, - mainPublicActivation: mode !== "default-scoped", privateDriftNativeWrites: mode !== "default-scoped" ? 0 : undefined, publicOverlapTOCTOU: mode !== "default-scoped" ? true : undefined, scopedSkipped: mode === "main-only", + mainPublicActivation: main, privateDriftNativeWrites: main ? 0 : undefined, publicOverlapTOCTOU: main ? true : undefined, scopedSkipped: mode === "main-only", scopedResult: scoped, defaultScopedInverseSuite: mode === "default-scoped" }, null, 2)) } catch (error) { failed = true; console.error(error) diff --git a/backend/src/test/users-profile-permissions.ts b/backend/src/test/users-profile-permissions.ts new file mode 100644 index 00000000..8dce25f1 --- /dev/null +++ b/backend/src/test/users-profile-permissions.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" +import type { FastifyInstance } from "fastify" +import { SignJWT } from "jose" +import { env } from "../config.js" + +export async function verifyUsersProfilePermissions(app: FastifyInstance) { + const marker = randomUUID() + const token = (sub: string, permissions: string[]) => new SignJWT({ sub, apps: ["mm"], permissions }) + .setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("5m") + .sign(new TextEncoder().encode(env.jwtSecret)) + const editor = { authorization: `Bearer ${await token(`users-editor-${marker}`, ["mm:users:write"])}` } + const admin = { authorization: `Bearer ${await token(`users-admin-${marker}`, ["mm:users:write", "mm:settings:admin"])}` } + const profile = { name: "Test profile", login: `profile-${marker}`, email: "fixture@example.invalid" } + const sections = [{ section: "traffic", level: "read" }] + const servers = [{ serverId: "1", level: "read" }] + const post = (extra: object) => app.inject({ method: "POST", url: "/api/users", headers: editor, payload: { ...profile, ...extra } }) + for (const protectedField of [{ role: "admin" }, { sections }, { servers }]) { + assert.equal((await post(protectedField)).statusCode, 403) + } + const created = await post({}) + assert.equal(created.statusCode, 201) + const { user } = created.json() + const url = `/api/users/${user.id}` + const patch = (payload: object, headers = editor) => app.inject({ method: "PATCH", url, payload, headers }) + try { + assert.equal(user.role, "viewer") + assert.deepEqual([user.sections, user.servers], [[], []]) + const granted = await patch({ role: "operator", sections, servers }, admin) + assert.equal(granted.statusCode, 200) + for (const protectedField of [{ role: "admin" }, { sections: [] }, { servers: [] }]) { + assert.equal((await patch({ name: "Blocked update", ...protectedField })).statusCode, 403) + } + const unchanged = await app.inject({ method: "GET", url, headers: admin }) + assert.equal(unchanged.statusCode, 200) + assert.equal(unchanged.json().user.name, profile.name) + const edited = await patch({ name: "Edited profile", active: false, role: "operator", sections, servers }) + assert.equal(edited.statusCode, 200) + const result = edited.json().user + assert.equal(result.name, "Edited profile") + assert.equal(result.active, false) + assert.equal(result.role, "operator") + assert.deepEqual(result.sections, sections) + assert.deepEqual(result.servers, servers) + console.log("✓ REST users: ordinary profile edits and settings-admin-only role, section and server permissions") + } finally { + assert.equal((await app.inject({ method: "DELETE", url, headers: admin })).statusCode, 204) + } +} diff --git a/components/mcp/mcp-workspace.tsx b/components/mcp/mcp-workspace.tsx index e6fd5046..61543b67 100644 --- a/components/mcp/mcp-workspace.tsx +++ b/components/mcp/mcp-workspace.tsx @@ -61,9 +61,9 @@ function ConnectionAccessStatus({ connection }: { connection: McpConnection }) { : ["Активен", "Срок доступа и права владельца действуют"] return
{label}{reason}
} -function useQuery(load: (signal: AbortSignal) => Promise) { - const [data, setData] = useState(null), [error, setError] = useState("") - const [updatedAt, setUpdatedAt] = useState(null) +function useQuery(load: (signal: AbortSignal) => Promise, key = "") { + const [response, setResponse] = useState<{ key: string; data: T; updatedAt: string } | null>(null) + const [failure, setFailure] = useState<{ key: string; message: string } | null>(null) const refresh = useCallback(() => { window.dispatchEvent(new Event("mcp-refresh")) }, []) useEffect(() => { let alive = true, revision = 0, pending = false, controller: AbortController | undefined, deadline: number | undefined @@ -74,10 +74,17 @@ function useQuery(load: (signal: AbortSignal) => Promise) { deadline = window.setTimeout(() => { if (!alive || requestRevision !== revision) return revision++; pending = false; controller?.abort() - setError("Обновление не завершилось за минуту. Следующая попытка выполнится автоматически.") + setFailure({ key, message: "Обновление не завершилось за минуту. Следующая попытка выполнится автоматически." }) }, 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)) } + try { + const result = await load(controller.signal) + if (alive && requestRevision === revision) { + setResponse({ key, data: result, updatedAt: new Date().toISOString() }) + setFailure(null) + } + } catch (error) { + if (alive && requestRevision === revision && !controller.signal.aborted) setFailure({ key, message: errorText(error) }) + } finally { if (requestRevision === revision) { window.clearTimeout(deadline); pending = false } } } queueMicrotask(() => { void run() }) @@ -85,8 +92,13 @@ function useQuery(load: (signal: AbortSignal) => Promise) { 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 } + }, [load, key]) + return { + data: response?.key === key ? response.data : null, + error: failure?.key === key ? failure.message : "", + refresh, + updatedAt: response?.key === key ? response.updatedAt : null, + } } function Panel({ title, description, children, actions }: { title: string; description?: string; children: ReactNode; actions?: ReactNode }) { return
{title}{description && {description}}
{actions}
{children} @@ -371,16 +383,28 @@ function Overview({ api }: { api: McpApi }) { const query = useQuery(useCallback(signal => api.overview(signal), [api])) return { if (window.confirm("Остановить новые обращения MCP и все профили Route AI? Начатые записи будут завершены или сверены.")) void api.stopAutomation().then(query.refresh, error => toast.error(errorText(error))) }}>Остановить автоматические изменения}>
{Object.entries({ connections: "Подключения", calls: "Вызовы за 24 часа", errors: "Ошибки и отказы", approvals: "Ожидают подтверждения", running: "Выполняются" }).map(([key, title]) =>

{title}

{query.data ? query.data[key as "calls"] : "—"}

)}
{query.data?.enabled ? "MCP включён" : "MCP выключен"}

Выключатель прекращает новые вызовы. Выключатель также останавливает новые решения Route AI. Отдельный профиль можно остановить на вкладке Route AI.

} -function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "operations" | "audit" | "drift" }) { +const recordQueryFilters = { + requests: ["tool", "status", "connectionId", "serverId", "userId", "from", "to"], + plans: ["status", "connectionId", "from", "to"], + operations: ["status", "connectionId", "from", "to"], + audit: ["connectionId", "userId", "from", "to"], + drift: ["from", "to"], +} as const + +function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFilters }) { const urlParams = useSearchParams(), [selected, setSelected] = useState(null) - const search = urlParams.get(kind + ".tool") ?? "", status = urlParams.get(kind + ".status") ?? "", cursor = urlParams.get(kind + ".cursor") ?? "" + const fields: readonly string[] = recordQueryFilters[kind] + const filterValue = (field: string) => urlParams.get(kind + "." + field) ?? "" + const cursor = filterValue("cursor") const setFilter = (key: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + key, value); else url.searchParams.delete(kind + "." + key); if (key !== "cursor") url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) } - const setSearch = (value: string) => setFilter("tool", value), setStatus = (value: string) => setFilter("status", value), setCursor = (value: string) => setFilter("cursor", value) const queryFields = new URLSearchParams({ limit: "50" }) - for (const field of ["tool", "status", "cursor", "connectionId", "serverId", "userId", "from", "to"]) { const value = urlParams.get(kind + "." + field); if (value) queryFields.set(field, value) } + for (const field of [...fields, "cursor"]) { + const value = filterValue(field) + if (value) queryFields.set(field, value) + } const queryKey = queryFields.toString() const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey]) - const query = useQuery(load) + const query = useQuery(load, `${kind}?${queryKey}`) const columns = useMemo[]>(() => [ { accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) }, { id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) }, @@ -416,12 +440,37 @@ function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "ope 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") &&
}
+ return ( + + +

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

+ +
+ {fields.includes("tool") && setFilter("tool", event.target.value)} />} + {fields.includes("status") && setFilter("status", event.target.value)} />} + +
+ +
+ Найдено: {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 RecordFilters({ fields, value, update }: { fields: readonly string[]; value: (field: string) => string; update: (field: string, value: string) => void }) { + return ( +
+ {fields.includes("connectionId") && update("connectionId", event.target.value)} />} + {fields.includes("serverId") && update("serverId", event.target.value)} />} + {fields.includes("userId") && update("userId", event.target.value)} />} + {["from", "to"].map(field => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} />)} +
+ ) } function ConnectionPolicySimulation({ api, connection, operations }: { api: McpApi diff --git a/components/users/user-sheet.tsx b/components/users/user-sheet.tsx index dcd95d9e..d36596dc 100644 --- a/components/users/user-sheet.tsx +++ b/components/users/user-sheet.tsx @@ -149,6 +149,7 @@ interface UserSheetProps { users: AppUser[] servers: UserServerOption[] isLive: boolean + canManagePermissions: boolean backendUrl: string saving?: boolean saveError?: string | null @@ -160,13 +161,17 @@ interface UserSheetProps { } function UserSheet({ - open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose, returnFocus, + open, user, users, servers, isLive, canManagePermissions, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose, returnFocus, }: UserSheetProps) { const saveErrorRef = useRef(null) useEffect(() => { if (saveError) saveErrorRef.current?.scrollIntoView({ block: "nearest" }) }, [saveError]) const isCreate = user === null const [sheetStep, setSheetStep] = useState(1) - const [form, setForm] = useState(() => (user ? toForm(user) : emptyForm(servers))) + const [form, setForm] = useState(() => { + if (user) return toForm(user) + const initial = emptyForm(servers) + return canManagePermissions ? initial : { ...initial, sections: [], servers: [] } + }) const [errors, setErrors] = useState>>({}) const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "") const [selectedPicks, setSelectedPicks] = useState([]) @@ -248,6 +253,7 @@ function UserSheet({ } const isAdmin = form.role === "admin" + const permissionsReadOnly = isAdmin || !canManagePermissions const validSelectedPicks = catalogReady ? selectedPicks.filter(pick => { const iface = catalog.find(item => item.name === pick.interfaceName && item.type === pick.type) @@ -375,8 +381,8 @@ function UserSheet({ /> {errors.email && {errors.email}} - - + {canManagePermissions ? + /> :

{ROLE_LABEL[form.role]}

}
Активный профиль @@ -401,10 +407,10 @@ function UserSheet({ key={o.v} type="button" onClick={() => setAllSections(o.v)} - disabled={isAdmin} + disabled={permissionsReadOnly} className={cn( "text-[11px] px-2 py-0.5 rounded border transition-colors", - isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer", + permissionsReadOnly ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer", PERM_COLOR[o.v], "border-border", )} > @@ -432,7 +438,7 @@ function UserSheet({ return (
{section} - setSectionPerm(section, v)} disabled={isAdmin} /> + setSectionPerm(section, v)} disabled={permissionsReadOnly} />
) })} @@ -449,10 +455,10 @@ function UserSheet({ key={o.v} type="button" onClick={() => setAllServers(o.v)} - disabled={isAdmin} + disabled={permissionsReadOnly} className={cn( "text-[11px] px-2 py-0.5 rounded border transition-colors", - isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer", + permissionsReadOnly ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer", PERM_COLOR[o.v], "border-border", )} > @@ -474,7 +480,7 @@ function UserSheet({

{srv.host} · {srv.site}

- setServerPerm(srv.id, v)} disabled={isAdmin} /> + setServerPerm(srv.id, v)} disabled={permissionsReadOnly} /> ) })} diff --git a/lib/user-save.ts b/lib/user-save.ts index ff2eb478..476dbca9 100644 --- a/lib/user-save.ts +++ b/lib/user-save.ts @@ -55,6 +55,7 @@ export async function saveUserSteps(form: AppUserForm, checkpoint: UserSaveCheck } return checkpoint.user } -export function userProfileRequest(form: AppUserForm) { - return { name: form.name.trim(), login: form.login.trim(), email: form.email.trim(), role: form.role, active: form.active, avatar: userInitials(form.name), sections: form.sections, servers: form.servers } +export function userProfileRequest(form: AppUserForm, canManagePermissions = true) { + const profile = { name: form.name.trim(), login: form.login.trim(), email: form.email.trim(), active: form.active, avatar: userInitials(form.name) } + return canManagePermissions ? { ...profile, role: form.role, sections: form.sections, servers: form.servers } : profile } diff --git a/shared/api/users.ts b/shared/api/users.ts index 55a6ec6c..17a02409 100644 --- a/shared/api/users.ts +++ b/shared/api/users.ts @@ -1,6 +1,7 @@ import { interfaceCatalogStateSchema, type InterfaceCatalogState, + appUserCreateSchema, appUserReadSchema, catalogInterfaceSchema, userBindingSchema, @@ -80,7 +81,7 @@ function confirmProfile(user: AppUserRead, data: AppUserCreate | AppUserUpdate) } } -export async function createAppUser(baseUrl: string, data: AppUserCreate, init?: RequestInit): Promise { +export async function createAppUser(baseUrl: string, data: z.input, init?: RequestInit): Promise { const payload = await requestJson(baseUrl, "/api/users", { ...init, method: "POST", diff --git a/tsconfig.json b/tsconfig.json index f44e565c..9a200e3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,13 +36,20 @@ ".next/dev/types/**/*.ts", "**/*.mts", "tmp/next-mcp-test/types/**/*.ts", - "tmp/next-mcp-test/dev/types/**/*.ts" + "tmp/next-mcp-test/dev/types/**/*.ts", + "tmp/next-mcp-auth-test/types/**/*.ts", + "tmp/next-mcp-auth-test/dev/types/**/*.ts", + "tmp/next-mcp-object-test/types/**/*.ts", + "tmp/next-mcp-object-test/dev/types/**/*.ts", + "tmp/next-logs-test/types/**/*.ts", + "tmp/next-logs-test/dev/types/**/*.ts" ], "exclude": [ "node_modules", "backend", "packages", "tmp", + "tmp-*/**", "temp", "test-results", "playwright-report"