feat(mcp): расширить управление маршрутизацией, доступом и обслуживанием базы
This commit is contained in:
1 parent
9240e8be11
commit
cffcfada88
125 files changed
+6021
-602
No files matched your search
@@ -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<AppUser[]>(() => 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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string,string>()
|
||||
for(let i=1;i<args.length;i+=2){assert(args[i].startsWith('--')&&flags.includes(args[i].slice(2))&&!values.has(args[i].slice(2))&&args[i+1]);values.set(args[i].slice(2),args[i+1])}
|
||||
@@ -32,34 +36,49 @@ export async function main(args:string[]){
|
||||
// All explicit trust files are checked before app helper/PG imports. No .env URL is used.
|
||||
// Existing source helper loads config/dotenv. Complete it BEFORE pg's first
|
||||
// import so a newly loaded NODE_PG_FORCE_NATIVE cannot select another driver.
|
||||
const {createDatabaseSourceBindingVerifier}=await import('../src/db/database-source-binding.js')
|
||||
const {createProtectedDatabaseSourceBindingVerifier}=await import('./writer-profile/protected-source-registry.mjs')
|
||||
assertJsDriverEnvironment(process.env)
|
||||
const [{default:pg},{createConnectedPublisher},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
|
||||
const [{default:pg},{createConnectedPublisher,requireCurrentCatalogueDefinition},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
|
||||
import('pg'),import('./writer-profile/publisher.mjs'),import('./writer-profile/catalogue.mjs')])
|
||||
assertJsDriverEnvironment(process.env)
|
||||
assert.equal(approvedDefinition(definition),pinned.definitionSha256)
|
||||
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[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<typeof collectSource44Catalogue>[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<typeof collectSource44Catalogue>[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
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
|
||||
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<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown,definition:unknown):{
|
||||
preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>
|
||||
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<T>(source:string,modules:Record<string,unknown>):T{
|
||||
const exports:Record<string,unknown>={};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<string,Buffer>()
|
||||
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<unknown>)=>{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<PublisherModule>(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<PublisherModule>(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<string,unknown>={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<string,unknown>={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<unknown>)=>{
|
||||
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<OperatorModule>(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<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
|
||||
result.platformProtectionVerified=true;protectedRefused=true;const before=retainedPools.length
|
||||
await assert.rejects(compile<OperatorModule>(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<OperatorModule>(cli,modules).main(commandArgs),/Protected source refused/)
|
||||
assert.equal(retainedPools.length,poolCount)
|
||||
protectedRefused=false
|
||||
events.length=0;reads.length=0
|
||||
const terminal=await compile<OperatorModule>(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<OperatorModule>(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<OperatorModule>(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<OperatorModule>(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<OperatorModule>(cli,modules).main(['reconcile',...args.slice(1)])
|
||||
assert.equal(historical.outcome,'confirmed');assert.equal(historical.readyAuthorized,false)
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "[email protected]", name: "Owned external SDK fixture", apps: ["mm"],
|
||||
@@ -28,7 +29,27 @@ let currentId: string | undefined, app: Awaited<ReturnType<typeof buildApp>> | u
|
||||
const credentials: string[] = []
|
||||
const bridgePids = new Set<number>()
|
||||
let childExit: Promise<number> | undefined
|
||||
let releaseLogRead: (() => void) | undefined
|
||||
let logReadCompleted: Promise<void> | undefined
|
||||
let revocationOwnerId: string | undefined
|
||||
const revokedLogRequests: { connectionId: string; tool: string }[] = []
|
||||
const originalConnect = pool.connect
|
||||
const delay = (ms: number) => new Promise<void>(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<void>(resolve => { completed = resolve })
|
||||
const hold = new Promise<void>(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() } }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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' : flowRebuildLine truncated
|
||||
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.testLine truncated
|
||||
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,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -7,7 +7,7 @@ export function approvedDefinition(value:unknown){
|
||||
assert(value&&typeof value==='object');const v=value as Record<string,unknown>
|
||||
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<string,unknown>[]} >},installation:{roleBindings:ConstructorParameters<typeof Fixed44CatalogueValueBridgeData>[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])}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
|
||||
@@ -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<typeof stateSchema>
|
||||
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<typeof createProtectedDatabaseSourceBindingVerifier>
|
||||
|
||||
/** Keep revocation and generation changes fenced throughout one read-only SQL preflight. */
|
||||
export async function withActiveSourcePreflight<T>(input:{directory:string,expectedSha256:string,sourcePin:z.infer<typeof sourcePin>,expectedSource:DatabaseSourceBindingBody['source'],tlsSpkiSha256:string,deadlineAt:number},
|
||||
verifier:SourceVerifier,work:(guardedVerifier:SourceVerifier)=>Promise<T>){
|
||||
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()<deadlineAt,'Activation preflight expired')
|
||||
const active=await current(directory)
|
||||
assert(active?.state.status==='active'&&active.activationSha256===expectedSha256,'Active activation digest changed')
|
||||
assert(Date.now()<deadlineAt,'Activation preflight expired')
|
||||
return active.state
|
||||
}
|
||||
const state=await readActive(),generation=state.generations.at(-1)!
|
||||
const registry=await readProtectedDatabaseSourceBindingRegistry(pin,{deadlineAt})
|
||||
assert(state.publicKeySpkiSha256===registry.publicKeySpkiSha256&&generation.tlsSpkiSha256===tlsSpkiSha256,'Activation key differs from preflight')
|
||||
function assertGeneration(evidence:{installationId:string,sourceIdentityId:string,generation:string,ordinal:number,headSha256:string,bindingSha256:string}){
|
||||
assert(evidence.installationId===state.installationId&&evidence.sourceIdentityId===state.sourceIdentityId&&
|
||||
evidence.generation===generation.generation&&evidence.ordinal===state.generations.length&&
|
||||
evidence.headSha256===generation.headSha256&&evidence.bindingSha256===generation.bindingSha256,'Activation differs from signed source generation')
|
||||
}
|
||||
assertGeneration({...registry.head,headSha256:registry.headSha256})
|
||||
assert.equal(sourceBindingCanonical(registry.body.source),sourceBindingCanonical(input.expectedSource),'Activation source differs from reviewed configuration')
|
||||
await readActive()
|
||||
let sourceChecks=0
|
||||
const guardedVerifier:SourceVerifier=Object.freeze({async verify(...args:Parameters<SourceVerifier['verify']>){
|
||||
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<string,string>()
|
||||
for(let i=1;i<args.length;i+=2){
|
||||
const name=args[i].slice(2)
|
||||
assert(args[i].startsWith('--')&&flags.includes(name)&&!values.has(name)&&args[i+1]);values.set(name,args[i+1])
|
||||
}
|
||||
const directory=values.get('directory')!
|
||||
return withJournalLease(directory,async()=>{
|
||||
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}
|
||||
})
|
||||
}
|
||||
@@ -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<unknown>){
|
||||
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<string,unknown>={'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<typeof withActiveSourcePreflight>[1]
|
||||
const verifierArgs=[] as unknown as Parameters<Verifier['verify']>
|
||||
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})}
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> }>;
|
||||
healthChecks?: Array<{ serverId: number; resource: string; id: string; field: "active" | "resolved" | "running" | "state" | "bfd-up"; phase?: "after"; identity?: Record<string, string>; nextHopInterface?: { name: string; id: string } }>;
|
||||
warnings: string[];
|
||||
}
|
||||
export interface McpAction {
|
||||
|
||||
@@ -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<T>(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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PreflightSource, PreflightObservation> {
|
||||
const result = Object.fromEntries(Object.keys(preflightPaths("ipv4")).map(key => [key, { rows: [], error: null }])) as Record<PreflightSource, PreflightObservation>
|
||||
result.routes.rows = [1, 2].map(index => ({ ".id": `*${index}`, "dst-address": "192.0.2.0/24", "routing-table": "main", gateway: `192.0.2.${index}`, "immediate-gw": `192.0.2.${index}%gre${index}`, disabled: "false", active: index === 1 ? "true" : "false", distance: String(index * 10) }))
|
||||
result.settings.rows = [{ "rp-filter": "no", "ipv4-multipath-hash-policy": "l3", "multipath-hash-policy": "l4" }]
|
||||
result.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<string, unknown>[]) => 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)
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
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<string>) {
|
||||
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<string, string>()
|
||||
const ambiguous = new Set<string>()
|
||||
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<typeof ecmpAdjacency>[]) {
|
||||
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<PreflightSource, PreflightObservation>) {
|
||||
export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record<PreflightSource, PreflightObservation>, 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<string>()
|
||||
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 }
|
||||
}
|
||||
@@ -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<string, unknown>) => ({ ...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"]
|
||||
|
||||
@@ -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<string, unknown>) => client.callTool({ name, arguments: args })
|
||||
const resultOf = async <T>(name: string, args: Record<string, unknown>) => {
|
||||
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<LogEntry>("mm_logs_get", { serverId: 1, id })).id, id)
|
||||
type LogContext = { entry: LogEntry; before: LogEntry[]; after: LogEntry[] }
|
||||
const context = await resultOf<LogContext>("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<LogEntry>("mm_logs_get", historical)).serverId, historicalId)
|
||||
const historicalContext = await resultOf<LogContext>("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<Export>("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({
|
||||
|
||||
@@ -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<string, Record<string, string>[]> = {}, mode = "normal", writes = 0, next = 0, polls = 0
|
||||
let connectionId: string | undefined
|
||||
let afterIntent: (() => Promise<void>) | undefined, afterVerified: (() => Promise<void>) | undefined
|
||||
let afterRouteRead: (() => Promise<void>) | undefined
|
||||
const query = pool.query.bind(pool)
|
||||
const commands: Array<{ method: string; path: string }> = []
|
||||
const readPaths = new Set<string>()
|
||||
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<string, string> : {}
|
||||
@@ -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<string, unknown>[] } }
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void>|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()<deadline;){
|
||||
waiting=(await pool.query(`SELECT EXISTS(SELECT 1 FROM pg_stat_activity
|
||||
WHERE $1=ANY(pg_blocking_pids(pid)) AND query LIKE '%FROM mcp_settings WHERE id=1 FOR SHARE%') AS waiting`,[pid])).rows[0].waiting
|
||||
if(!waiting)await delay(10)
|
||||
}
|
||||
assert(waiting,"retention worker must wait for the policy update before choosing candidates")
|
||||
assert.deepEqual(await getMcpSettings(),{...original,requestsRetentionDays:1,auditRetentionDays:30})
|
||||
await settingsWriter.query("COMMIT")
|
||||
transactionOpen=false
|
||||
await tick
|
||||
assert.deepEqual(await getMcpSettings(),{...original,requestsRetentionDays:30,auditRetentionDays:180})
|
||||
assert.deepEqual((await pool.query("SELECT tool,status FROM mcp_requests WHERE actor_id=$1 ORDER BY tool",[actorId])).rows,
|
||||
[{tool:"active",status:"running"},{tool:"protected",status:"completed"},{tool:"recent",status:"completed"}])
|
||||
assert.deepEqual((await pool.query("SELECT action FROM mcp_audit WHERE actor_id=$1 ORDER BY action",[actorId])).rows,
|
||||
[{action:"protected"},{action:"recent"}])
|
||||
}finally{
|
||||
try{if(transactionOpen)await settingsWriter.query("ROLLBACK")}finally{settingsWriter.release()}
|
||||
await tick?.catch(()=>{})
|
||||
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<string,unknown>={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<typeof retireOrdinaryMcpOperations>|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()<deadline;){
|
||||
waiting=(await exclusive.query(`SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_locks
|
||||
WHERE locktype='advisory' AND database=(SELECT oid FROM pg_catalog.pg_database WHERE datname=current_database())
|
||||
AND classid=0 AND objid=$1::oid AND objsubid=1 AND mode='ShareLock' AND NOT granted) AS waiting`,[PARTITION_WRITER_ADVISORY_KEY])).rows[0].waiting
|
||||
if(!waiting)await delay(10)
|
||||
}
|
||||
assert.equal(waiting,true,"the retiring checkout must wait at the common fence")
|
||||
assert.equal((await exclusive.query("SELECT id FROM public.mcp_connections WHERE id=$1 FOR UPDATE NOWAIT",[principal.connectionId])).rows.length,1)
|
||||
for(const candidate of [spent,due]){
|
||||
const {rows:[row]}=await exclusive.query("SELECT pg_try_advisory_xact_lock(hashtextextended($1,0)) AS locked",["mcp-operation:"+candidate.id])
|
||||
assert.equal(row.locked,true,"the retiring checkout must not hold a secondary operation key")
|
||||
}
|
||||
const blocked=await blockedAttempt
|
||||
assert(blocked.scanned>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{
|
||||
|
||||
@@ -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<T>(deadlineAt:number, work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T> {
|
||||
async function transaction<T>(deadlineAt:number, mode:"scan"|"write", work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T> {
|
||||
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){
|
||||
|
||||
@@ -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<string, RosSnapshot>(), 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 }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
export interface RosStep { serverId: number; resource: string; action: "create" | "update" | "delete"; id?: string; fields: Record<string, string>; 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<ConfigurationRow[]> {
|
||||
if (!Array.isArray(payload)) throw new McpError("STATE_CONFLICT", "Каталог политики маршрутизации не подтверждён", 409)
|
||||
const identify = await privateFingerprinter(`mcp-routing-policy-v1:${resource}`)
|
||||
const ids = new Set<string>()
|
||||
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<unknown> = async () => {}, expectedSourceProof?: string): Promise<RosSnapshot> {
|
||||
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<unknown>({ 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<ReturnType<typeof routerClient>>["server"]): Promise<string> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,6 +36,13 @@ function project(output: unknown, projection: ResultProjection): Record<string,
|
||||
const receipt: Record<string, unknown> = { 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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<typeof routeAiProfileSchema>) => [...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<typeof routeAiProfileSchema>, 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<typeof routeAiProfileSchema>, 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<string, unknown>, 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<string, unknown>, context: ActionContext, mode: "read" | "write" = "write"): Promise<ChangeEvidence & { before: { ranking: OspfRank[] } }> {
|
||||
|
||||
@@ -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<AnalysisPage>
|
||||
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])
|
||||
|
||||
@@ -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<void>; signal?: AbortSignal; expectedSources?: Array<{ serverId: number; proof: string }>; selectedCandidateId?: string; managementTransport?: ManagementTransportCapability }
|
||||
export type OwnedRouteCommunityTarget = { nativeId: string; rawRow: NativeBgpRow; endpoint: Readonly<RoutingEndpoint>; desiredTargetProof: (ruleBody: string) => string }
|
||||
export type OwnedRouteCommunityTarget = { nativeId: string; rawRow: NativeBgpRow; endpoint: Readonly<RoutingEndpoint>; 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<string, unknown>[] }
|
||||
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 }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChangeEvidence } from "./action-types.js"
|
||||
import { isRoutingPolicySnapshot } from "./resources.js"
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>): Promise<Record<string, unknown>>
|
||||
approve(plan: Awaited<ReturnType<typeof publicPlan>>): Promise<void>
|
||||
verifyOperation(operation: { id: string; planId: string }): Promise<void>
|
||||
}
|
||||
|
||||
type BrowserRoutingJourney = <T>(access: { token: string; connectionId: string; serverId: number },
|
||||
run: (transport: BrowserRoutingTransport) => Promise<T>) => Promise<T>
|
||||
|
||||
/** 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<string, unknown>, 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<Record<string, unknown>> = []
|
||||
let publicManualRevision2: Record<string, unknown> | undefined
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<string, unknown>; 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,
|
||||
|
||||
@@ -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')))"))
|
||||
}
|
||||
@@ -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<string, unknown>, 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<string, unknown>, context: { before: Parameters<typeof scopedConfigurationMatches>[1] }) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before)
|
||||
async function matchesDesired(input: Record<string, unknown>, context: { before: Parameters<typeof scopedConfigurationMatches>[1]; ownedTarget: Pick<OwnedRouteCommunityTarget, "nativeId" | "matchesRuleProof"> | 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<string, unknown>, verified: boolean) {
|
||||
await sealedInput(input)
|
||||
const value = await observe(input, undefined, verified)
|
||||
@@ -125,7 +133,7 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
||||
const outIface = value.candidate.gatewayRef.resource === "gre" ? String(target[0].name) : ""
|
||||
if (!candidateMatchesGateway(value.profile, value.candidate, { gateway: value.candidate.gateway, gatewayInterface: outIface }, target[0])) throw fail("Gateway кандидата не соответствует exact native identity")
|
||||
const compiled = compileScopedCommunitySwitch(String(owned.rawRow.rule), value.profile.community, value.candidate.gateway, outIface, value.profile.family)
|
||||
if (compiled.compiledBody === owned.rawRow.rule) throw fail("Путь уже выбран")
|
||||
if (managedRoutingFilterBodiesMatch(owned.rawRow.rule, compiled.compiledBody)) throw fail("Путь уже выбран")
|
||||
let managementBindingCiphertext: string | null = null
|
||||
const hasChannel = (await pool.query("SELECT id FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [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
|
||||
|
||||
@@ -12,7 +12,7 @@ async function fixture() {
|
||||
const input: Record<string, unknown> = { profile: { autoRecovery: false }, persistentProfileAuthority: authority, nativeId: "*1", originalCandidateId: "a", originalTargetProof: "target-a", dependencyProof: "dep", contextSources: enrolled.sourceProofs }
|
||||
const prepared: Record<string, unknown> = { ...input, desiredCandidateId: "b" }
|
||||
const packet: Record<string, unknown> = { 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) })
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
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
|
||||
|
||||
@@ -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<typeof Baseline.prepareRoutingControllerBaseline>[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<string, unknown> = {
|
||||
"./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<void> }) => {
|
||||
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<typeof stableTarget> = {}, 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" })
|
||||
})
|
||||
@@ -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<typeof baselineSchema>
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<unknown>(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<PreflightSource, PreflightObservation>) }
|
||||
const read = async (path: string, singleton = false): Promise<PreflightObservation> => {
|
||||
try { return preflightObservation(await client.get<unknown>(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<PreflightSource, PreflightObservation>
|
||||
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<ConfigurationRow[]>(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<unknown>({ 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<string, string> | 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(), {
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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<ChangeEvidence, "healthChecks" | "snapshots">, serverIds: number[], afterWrite: boolean, authorize?: () => Promise<unknown>) {
|
||||
const cache = new Map<string, unknown[]>()
|
||||
const interfaces = new Map<number, Map<string, string>>()
|
||||
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<unknown>(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<string, unknown> => !!value && typeof value === "object" && (value as Record<string, unknown>)[".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)
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,7 @@ function retentionOwner(f:Awaited<ReturnType<typeof retentionFixture>>,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'))<owner.calls.findIndex(s=>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 && admission<settings && settings<owner.calls.findIndex(s=>s.startsWith('SELECT c.recovery_operation_id')))
|
||||
const candidate=owner.calls.find(s=>s.startsWith('SELECT c.recovery_operation_id'))!
|
||||
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
|
||||
assert(candidate.includes('r.authority_profile_version IS NOT NULL'))
|
||||
@@ -180,16 +183,16 @@ test('retention MODEL refuses recent/future/malformed copy, present corrupt pare
|
||||
f.parent.binding_proof='0'.repeat(64)
|
||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(f).db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/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('{}')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
"./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<void>; 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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<type
|
||||
deadline: number; contextSources: Array<{ serverId: number; proof: string }>; 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<void>;
|
||||
ownedTarget: { nativeId: string; rawRow: Row } | undefined }
|
||||
ownedTarget: Pick<OwnedRouteCommunityTarget, "nativeId" | "rawRow" | "matchesRuleProof"> | undefined }
|
||||
type SharedOwner = { binding: SharedBinding; active(): void; fence(): Promise<void>; close(): void;
|
||||
read(serverId: number, path: string, body?: Record<string, string>): Promise<unknown>; run(): Promise<SharedContextResult> }
|
||||
const sharedContexts = new WeakMap<RoutingNetworkSharedContext, SharedOwner>()
|
||||
|
||||
@@ -47,7 +47,7 @@ const collections = (path: string): Record<string, unknown>[] => {
|
||||
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" }))
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { ChangeEvidence } from "./action-types.js"
|
||||
import { fingerprint, McpError } from "./security.js"
|
||||
|
||||
type Checks = NonNullable<ChangeEvidence["observationChecks"]>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<number, RouteObservation[]>()
|
||||
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)
|
||||
}
|
||||
@@ -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')<c.indexOf('PATCH'));assert(c.indexOf('CONFIG_APPLIED')<c.indexOf('configuration-round-owner'));assert(c.indexOf('legacy-probe')<c.indexOf('round:1'));assert(c.indexOf('round:2')<c.indexOf('shared-publication'));assert(c.indexOf('shared-publication')<c.indexOf('save-network'));assert.equal(c.filter(x=>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();
|
||||
|
||||
@@ -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];});
|
||||
|
||||
@@ -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<string, unknown>) => ({ scope: "servers" as const, serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) })
|
||||
const prepareProfileSave = (updateOnly: boolean) => async (input: Record<string, unknown>, 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<typeof rankRoutingCandidates>; currentCandidateId: string | null }
|
||||
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile, limit: 100 }) as { ranking: ReturnType<typeof rankRoutingCandidates>; 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 }
|
||||
|
||||
@@ -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<void>) {
|
||||
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<unknown> {
|
||||
@@ -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<typeof createVrfRuntimeDiagnosticBoundary> | undefined
|
||||
let reservedForwardPassed = false
|
||||
let reservedRecoverySummary: Record<string, unknown> | 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<unknown> } = {}
|
||||
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<string, unknown[]>()
|
||||
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<unknown>(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<string, unknown> => !!value && typeof value === "object" && (value as Record<string, unknown>)[".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<now()-interval '2 minutes' ORDER BY created_at LIMIT 100")
|
||||
for (const row of rows) {
|
||||
await transaction(async client => {
|
||||
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<now() AND state IN ('draft','approved','awaiting-approval')")
|
||||
await pool.query("DELETE FROM mcp_read_leases WHERE expires_at<now()")
|
||||
await pool.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'))")
|
||||
await withPartitionWrite(({ client }) => client.query("UPDATE mcp_plans SET state='expired' WHERE expires_at<now() AND state IN ('draft','approved','awaiting-approval')"))
|
||||
await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_read_leases WHERE expires_at<now()"))
|
||||
await withPartitionWrite(({ client }) => 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'))"))
|
||||
}
|
||||
@@ -116,6 +116,7 @@ export async function decryptBinary(value: Buffer): Promise<Buffer> {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -57,8 +57,9 @@ export async function transaction<T>(work: (client: PoolClient) => Promise<T>):
|
||||
export async function audit(client: Pick<PoolClient, "query">, actorId: string, action: string, connectionId: string | null, payload: unknown): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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<DriftCheckpoint>(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<DriftCheckpoint>(
|
||||
`${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<now()-make_interval(days=>$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<now()-make_interval(days=>$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<now()-make_interval(days=>$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<now()-make_interval(days=>$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<now()
|
||||
ORDER BY expires_at,id LIMIT $1 FOR UPDATE
|
||||
) DELETE FROM mcp_artifacts USING expired WHERE mcp_artifacts.id=expired.id`, [mcpRetentionBatchSize])
|
||||
await pool.query("DELETE FROM mcp_oauth_requests WHERE expires_at<now()-interval '1 day'")
|
||||
await pool.query("DELETE FROM mcp_budget_buckets WHERE bucket<now()-interval '2 days'")
|
||||
await routingReservationTransaction(db => retainManualInverseChargesWithinOwner(db, settings.requestsRetentionDays))
|
||||
await pool.query(`WITH candidates AS MATERIALIZED (SELECT source_operation_id FROM mcp_routing_forward_charges
|
||||
WHERE created_at<now()-make_interval(days=>$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<now()-interval '7 days'")
|
||||
await pool.query("DELETE FROM mcp_path_checks WHERE created_at<now()-interval '7 days'")
|
||||
await pool.query("DELETE FROM mcp_unauthorized_buckets WHERE bucket<now()-interval '7 days'")
|
||||
await pool.query("DELETE FROM mcp_portal_events WHERE created_at<now()-interval '7 days'")
|
||||
) DELETE FROM mcp_artifacts USING expired WHERE mcp_artifacts.id=expired.id`, [mcpRetentionBatchSize]))
|
||||
await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_oauth_requests WHERE expires_at<now()-interval '1 day'"))
|
||||
await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_budget_buckets WHERE bucket<now()-interval '2 days'"))
|
||||
await withPartitionWrite(({ client }) => 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<now()-make_interval(days=>$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<now()-interval '7 days'"))
|
||||
await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_path_checks WHERE created_at<now()-interval '7 days'"))
|
||||
await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_unauthorized_buckets WHERE bucket<now()-interval '7 days'"))
|
||||
await withPartitionWrite(({ client }) => client.query("DELETE FROM mcp_portal_events WHERE created_at<now()-interval '7 days'"))
|
||||
}
|
||||
|
||||
export async function tickMcpWorkers() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { toServerRead } from "../mapper/servers-mapper.js"
|
||||
import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js"
|
||||
import { executeGuardedApplicationWrite } from "../../../services/config-mutation-policy.js"
|
||||
import { withConfigMutationLock } from "../../../services/config-mutation-lock.js"
|
||||
|
||||
export async function listServersRead(): Promise<ServerRead[]> {
|
||||
const rows = await listServerRows()
|
||||
@@ -36,7 +37,9 @@ async function createServerImpl(input: ServerCreate): Promise<ServerRead> {
|
||||
return toServerRead(inserted, undefined)
|
||||
}
|
||||
|
||||
export function updateServer(id: number, input: ServerUpdate): Promise<ServerRead> { return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => updateServerImpl(id, input)) }
|
||||
export function updateServer(id: number, input: ServerUpdate): Promise<ServerRead> {
|
||||
return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => withConfigMutationLock([id], () => updateServerImpl(id, input)))
|
||||
}
|
||||
async function updateServerImpl(id: number, input: ServerUpdate): Promise<ServerRead> {
|
||||
const { wanUplinks, ...rest } = input
|
||||
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
|
||||
@@ -50,7 +53,9 @@ async function updateServerImpl(id: number, input: ServerUpdate): Promise<Server
|
||||
return toServerRead(updated, await getLatestSnapshot(updated.id))
|
||||
}
|
||||
|
||||
export function deleteServer(id: number): Promise<void> { return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => deleteServerImpl(id)) }
|
||||
export function deleteServer(id: number): Promise<void> {
|
||||
return executeGuardedApplicationWrite({ kind: "server", id: String(id) }, [id], () => withConfigMutationLock([id], () => deleteServerImpl(id)))
|
||||
}
|
||||
async function deleteServerImpl(id: number): Promise<void> {
|
||||
await deleteServerRowById(id)
|
||||
}
|
||||
|
||||
@@ -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<AppUserRead> { 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<AppUserRead> {
|
||||
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<AppUserRead> {
|
||||
const login = input.login.trim()
|
||||
if (await getUserRowByLogin(login)) {
|
||||
@@ -161,10 +170,17 @@ async function createUserImpl(input: AppUserCreate): Promise<AppUserRead> {
|
||||
return await toUserDto(row, [])
|
||||
}
|
||||
|
||||
export function updateUser(id: string, input: AppUserUpdate): Promise<AppUserRead> { 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<AppUserRead> {
|
||||
export function updateUser(id: string, input: AppUserUpdate, { canManagePermissions = true }: UserWriteOptions = {}): Promise<AppUserRead> {
|
||||
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<AppUserRead> {
|
||||
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<SectionPerm>(existing.sectionsJson, [])))
|
||||
|| (input.servers != null && !isDeepStrictEqual(input.servers, asPermArray<ServerPerm>(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<AppUser
|
||||
if (input.name != null) patch.name = input.name.trim()
|
||||
if (input.login != null) patch.login = input.login.trim()
|
||||
if (input.email != null) patch.email = input.email.trim()
|
||||
if (input.role != null) patch.role = input.role
|
||||
if (input.active != null) patch.active = input.active
|
||||
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
|
||||
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
|
||||
if (input.sections != null) patch.sectionsJson = input.sections
|
||||
if (input.servers != null) patch.serversJson = input.servers
|
||||
if (canManagePermissions) {
|
||||
if (input.role != null) patch.role = input.role
|
||||
if (input.sections != null) patch.sectionsJson = input.sections
|
||||
if (input.servers != null) patch.serversJson = input.servers
|
||||
}
|
||||
const updated = await updateUserRowById(id, patch)
|
||||
return await toUserDto(updated, await listBindingRowsByUser(id))
|
||||
}
|
||||
@@ -369,9 +387,12 @@ export async function getInterfaceCatalogState(serverId: number): Promise<Interf
|
||||
return { serverId, source, sampledAt, inventoryComplete: inventory.known, interfaces: entries.sort((a, b) => a.name.localeCompare(b.name)), failures }
|
||||
}
|
||||
|
||||
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||
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<CatalogInterface[]> {
|
||||
return projectLegacyInterfaceCatalog(await getInterfaceCatalogState(serverId))
|
||||
}
|
||||
|
||||
export { parseRawInterfaces, mapRosInterfaceType }
|
||||
@@ -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<typeof listActions>, 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, {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof fromJsonSchema>[0]), annotations: tool.annotations,
|
||||
}, async (input, context) => {
|
||||
guard.protectCallback(context.mcpReq.id)
|
||||
return remote.callTool({ name: tool.name, arguments: input as Record<string, unknown> })
|
||||
})
|
||||
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()]) })
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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." }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, un
|
||||
const owner = resolveOspfLinkOwner(serverId, links.get(serverId)!, instance[".id"], matches[0][".id"], [...links].map(([id, catalog]) => ({ 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<string, unknown>[], 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<string, un
|
||||
const healthChecks: NonNullable<ChangeEvidence["healthChecks"]> = 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<NonNullable<ChangeEvidence["observationChecks"]>[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 }
|
||||
}
|
||||
@@ -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<PoolClient, "query">, options: Omit<PartitionWriteOptions, "connect"> = {}): Promise<void> {
|
||||
export async function admitPartitionWriter(client: { query(text: string, values?: [number]): Promise<unknown> }, options: Omit<PartitionWriteOptions, "connect"> = {}): Promise<void> {
|
||||
const current = settings(options)
|
||||
const check = async () => {
|
||||
if (current.signal?.aborted) throw new PartitionWriteError()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<void>;destroy:()=>void}
|
||||
interface TransactionScope {holder:Holder;active:boolean;readOnly:boolean;savepoint:number;tail:Promise<void>;parent?:TransactionScope}
|
||||
export interface ServerPollJournalOwner {
|
||||
transaction<T>(options:ProbeStorageOptions,authorize:()=>Promise<void>,work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T>
|
||||
assertClosedForQuarantine():void
|
||||
}
|
||||
const journalOwners=new WeakSet<ServerPollJournalOwner>()
|
||||
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<Holder>(),transactions=new AsyncLocalStorage<TransactionScope>()
|
||||
const now=deps.now??Date.now,held=new AsyncLocalStorage<Holder>(),transactions=new AsyncLocalStorage<TransactionScope>(),journal=new AsyncLocalStorage<Holder>()
|
||||
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<T>(readOnly:boolean,fence:()=>Promise<void>,options:ServerPartitionTransactionOptions,work:(query:ServerPartitionQuery)=>Promise<T>):Promise<T>=>{
|
||||
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<T>(options:ProbeStorageOptions,authorize:()=>Promise<void>,work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T>{
|
||||
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<void>(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<T>(work:()=>Promise<T>,options:ServerPartitionLeaseOptions):Promise<T>=>{
|
||||
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}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ async function compact(n: number, owner: string): Promise<Entry> {
|
||||
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<typeof assertServerPollArchiveAccountingForTests>[2]) {
|
||||
function fixture(entries: Entry[], retiredFactory?: Parameters<typeof assertServerPollArchiveAccountingForTests>[2]) {
|
||||
const totals = new Map<string, { heavy: number; compact: number; archiveBytes: number; bytes: number; rows: number }>()
|
||||
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<typeof assertServe
|
||||
} }
|
||||
return { entries, usage, state, queries, batches, usageBatches, counts: () => ({ factories, charges }), run: () => assertServerPollArchiveAccountingForTests(sql, async () => {
|
||||
factories++; return async row => { charges++; return codec.verifyCharge(row) }
|
||||
}, retiredCharge) }
|
||||
}, retiredFactory) }
|
||||
}
|
||||
const refused = (work: Promise<unknown>) => 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 () => {
|
||||
|
||||
@@ -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<typeof verifyServerPollArchiveCharge>,
|
||||
retiredCharge: typeof verifyServerPollRetiredArchiveCharge) {
|
||||
retiredFactory: typeof createServerPollRetiredArchiveChargeVerifier) {
|
||||
const global = zero(), owners = new Map<string, Total>()
|
||||
const seenReceipts = new Set<string>()
|
||||
let cursor: string | null = null, count = 0, charge: typeof verifyServerPollArchiveCharge | undefined
|
||||
let retiredCharge: Awaited<ReturnType<typeof retiredFactory>> | 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<typeof verify
|
||||
charge ??= await factory()
|
||||
verified = await charge(row)
|
||||
} else if (row.version === 2) {
|
||||
retiredCharge ??= await retiredFactory()
|
||||
verified = await retiredCharge(row)
|
||||
} else return fail()
|
||||
addition = { heavy: 0, compact: 1, archiveBytes: verified.metadata.ciphertext_bytes, rows: verified.rows, bytes: verified.bytes }
|
||||
@@ -111,9 +113,9 @@ async function verify(sql: ProbeStorageSql, factory: () => Promise<typeof verify
|
||||
/** Caller must already own server-poll-storage-v1. Any malformed/missing MAC or
|
||||
* counter refuses admission before an INSERT, credential read or native I/O. */
|
||||
export async function assertServerPollArchiveAccounting(sql: ProbeStorageSql): Promise<void> {
|
||||
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<typeof verifyServerPollArchiveCharge>,
|
||||
retiredCharge = verifyRetiredCharge): Promise<void> {
|
||||
try { await verify(sql, factory, retiredCharge) } catch { return fail() }
|
||||
retiredFactory = createRetiredChargeVerifier): Promise<void> {
|
||||
try { await verify(sql, factory, retiredFactory) } catch { return fail() }
|
||||
}
|
||||
@@ -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<void>((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<typeof Client>&{processID?:number}
|
||||
let reservedBackend:IdentifiedClient|undefined,acksLost=0
|
||||
try{
|
||||
Client.prototype.query=function(this:InstanceType<typeof Client>,...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<void>(resolve=>listener.close(()=>resolve()))
|
||||
|
||||
@@ -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<never>=>{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
|
||||
|
||||
@@ -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<void>){
|
||||
const transactions:{commands:string[];release?:boolean}[]=[]
|
||||
let commits=0
|
||||
return {transactions,deps:{now:Date.now,connect:async(options:Parameters<typeof connectProbeStorage>[0])=>{
|
||||
const physical=await connectProbeStorage(options),trace:{commands:string[];release?:boolean}={commands:[]}
|
||||
transactions.push(trace)
|
||||
return {async query(...args:Parameters<typeof physical.query>){
|
||||
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<typeof loseMaintenanceCommit>,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;phase<phaseCount;phase++){
|
||||
const commit=commands.indexOf("COMMIT",previousCommit+1),sql=commands.slice(previousCommit+1,commit)
|
||||
const writer=sql.indexOf("SELECT pg_advisory_xact_lock_shared($1::bigint)")
|
||||
const restored=sql.indexOf("SELECT pg_catalog.set_config('lock_timeout',$1,true)")
|
||||
const storage=sql.indexOf("SELECT pg_advisory_xact_lock(hashtextextended('server-poll-storage-v1',0))")
|
||||
assert(writer>0&&writer<restored&&restored<storage)
|
||||
previousCommit=commit
|
||||
}
|
||||
}
|
||||
|
||||
async function historical(days:number,serverId:number) {
|
||||
const base=Date.now()-days*86400000, stamp=(offset:number)=>new 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<unknown>|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()<end;){
|
||||
const {rows:[lock]}=await pool.query("SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' AND pid=$1 AND objid=$2::oid AND NOT granted) AS queued",[owner.pid,PARTITION_WRITER_ADVISORY_KEY])
|
||||
queued=lock.queued;if(!queued)await delay(10)
|
||||
}
|
||||
assert.equal(queued,true,"Exclusive POLL must wait across the acknowledged compaction claim")
|
||||
})
|
||||
await assert.rejects(compactCompletedServerPollStorageForTests(fault.deps),error=>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<typeof retireCompletedServerPollOperation>|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()<deadline;){
|
||||
waiting=(await exclusive.query(`SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_locks
|
||||
WHERE locktype='advisory' AND database=(SELECT oid FROM pg_catalog.pg_database WHERE datname=current_database())
|
||||
AND classid=0 AND objid=$1::oid AND objsubid=1 AND mode='ShareLock' AND NOT granted) AS waiting`,[PARTITION_WRITER_ADVISORY_KEY])).rows[0].waiting
|
||||
if(!waiting)await delay(10)
|
||||
}
|
||||
assert.equal(waiting,true,"POLL retirement must wait on the common writer fence")
|
||||
for(const key of ["server-poll-storage-v1","mcp-operation:"+fixture.executionId,"server-poll-execution-v1:mcp:"+fixture.executionId,"server-poll-receipt-v1:"+fixture.receiptId]){
|
||||
assert.equal((await exclusive.query("SELECT pg_try_advisory_xact_lock(hashtextextended($1,0)) AS locked",[key])).rows[0].locked,true,key)
|
||||
}
|
||||
assert.equal((await exclusive.query("SELECT id FROM public.mcp_connections WHERE id=$1 FOR UPDATE NOWAIT",[fixture.connectionId])).rows.length,1)
|
||||
await assert.rejects(blocked,PollStorageRefused)
|
||||
assert.equal((await exclusive.query("SELECT 1 FROM server_poll_retirement_claims WHERE receipt_id=$1",[fixture.receiptId])).rows.length,0)
|
||||
assert.equal((await exclusive.query("SELECT 1 FROM mcp_operation_replay_tombstones WHERE operation_id=$1",[fixture.executionId])).rows.length,0)
|
||||
assert.equal((await exclusive.query(SERVER_POLL_ARCHIVE_READ_SQL,[fixture.receiptId])).rows[0].archive_proof,v1.archive_proof)
|
||||
}finally{
|
||||
try{await blocked?.catch(()=>{})}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<typeof Client>,...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()}
|
||||
@@ -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 = <T>(deadlineAt:number, work:(sql:ProbeStorageSql)=>Promise<T>) =>
|
||||
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 = <T>(deadlineAt:number,mode:"scan"|"write",work:(sql:ProbeStorageSql)=>Promise<T>) =>
|
||||
runPollStorageTransaction(deps,{deadlineAt},async()=>{},work,mode==="write"?sql=>admitRetirementWriter(sql,deadlineAt):undefined)
|
||||
type Target = z.infer<typeof request>
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string,unknown>[];rowCount?:number|null}>
|
||||
query(text:string,values:unknown[],options:{signal?:AbortSignal;timeoutMs:number;statementName?:string}):Promise<{rows:Record<string,unknown>[];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<ServerPollSqlConnection>
|
||||
@@ -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<Record<string,unknown>>(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<Record<string,unknown>>(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),
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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 }))
|
||||
@@ -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<Dependencies, "createStorage"> & Partial<Omit<Dependencies, "createStorage">>) => construct(context, options, { ...production, ...dependencies })
|
||||
@@ -15,13 +15,15 @@ function fixture(openRetired?: Parameters<typeof collectExpiredServerPollStorage
|
||||
{ receipt_id: randomUUID(), owner_id: "owned-gc", archive_version: 1 }]
|
||||
const retiredRows: Record<string, unknown>[] = []
|
||||
const commands: { text: string; values: unknown[]; timeout: number }[] = [], releases: boolean[] = [], options: ProbeStorageOptions[] = []
|
||||
let settings: Record<string, unknown>[] = [{ requests_retention_days: 30 }], fail = "", residual = "", removed: Record<string, unknown>[] | undefined, hold: ReturnType<typeof deferred> | undefined
|
||||
let settings: Record<string, unknown>[] = [{ requests_retention_days: 30 }], fail = "", residual = "", removed: Record<string, unknown>[] | undefined, hold: ReturnType<typeof deferred> | 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<typeof collectExpiredServerPollStorage
|
||||
return { rows: [] }
|
||||
}, release(unsafe = false) { releases.push(unsafe) } }
|
||||
} }, openRetired)
|
||||
return { run, now, candidates, retiredRows, commands, releases, options, entered: entered.promise, block: () => { 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<string, unknown>[]) => { settings = rows }, fail: (text: string) => { fail = text }, residual: (table: string) => { residual = table }, removed: (rows: Record<string, unknown>[]) => { 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<clock_timestamp()")); assert(selection.includes("server_poll_storage_gc_allowed(receipt_id)")); assert(selection.endsWith("LIMIT 8 FOR UPDATE"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Private fixed poll GC. Not scheduled or exposed by this opt-in module. */
|
||||
import { sharedDatabaseGate } from "./shared-database-gate.js"
|
||||
import { connectProbeStorage } from "./server-probe-storage-connection.js"
|
||||
import { runPollStorageTransaction, type PollStorageDependencies } from "./server-poll-storage.js"
|
||||
import { admitPollStorageWriter, runPollStorageTransaction, tryAdmitPollStorageMaintenanceWriter, type PollStorageDependencies } from "./server-poll-storage.js"
|
||||
import { PollStorageRefused } from "./server-poll-storage-model.js"
|
||||
import { assertServerPollArchiveAccounting } from "./server-poll-archive-accounting.js"
|
||||
import type { openServerPollRetiredArchive } from "./server-poll-retired-archive.js"
|
||||
@@ -27,7 +27,8 @@ const retiredCandidate = `SELECT r.version,r.receipt_id,r.execution_id,r.connect
|
||||
FOR UPDATE OF r`
|
||||
|
||||
async function collect(deps:PollStorageDependencies, openRetired:typeof openServerPollRetiredArchive){
|
||||
return runPollStorageTransaction(deps,{deadlineAt:deps.now()+20000},async()=>{},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))
|
||||
|
||||
@@ -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<void>{
|
||||
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<boolean>{
|
||||
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<T>=(sql:ProbeStorageSql)=>Promise<void|{deferred:T}>
|
||||
|
||||
/** 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<T>(deps:PollStorageDependencies,options:ProbeStorageOptions,authorize:()=>Promise<void>,work:(sql:ProbeStorageSql)=>Promise<T>):Promise<T>{
|
||||
* 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<T>(deps:PollStorageDependencies,options:ProbeStorageOptions,authorize:()=>Promise<void>,work:(sql:ProbeStorageSql)=>Promise<T>,beforeStorageLock?:PollStoragePrelude<T>):Promise<T>{
|
||||
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<T>(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<T>(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<T>(options:ProbeStorageOptions,work:(sql:ProbeStorageSql)=>Promise<T>)=>{
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
}
|
||||
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<never> => { 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<string, string>()
|
||||
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)
|
||||
|
||||
@@ -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<typeof compactionWriter>,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))
|
||||
|
||||
@@ -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<string,unknown>[]}> }
|
||||
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<T>(ownerId:string,operation:"claim"|"publish"|"read"|"purge"|"cancel",bound:ProbeStorageScope|undefined,options:ProbeStorageOptions,work:(sql:ProbeStorageSql,policy:ProbeStoragePolicy,bind:(value:ProbeStorageScope)=>Promise<void>)=>Promise<T>):Promise<T>=>{
|
||||
const run=async<T>(ownerId:string,operation:"claim"|"publish"|"read"|"purge"|"cancel",bound:ProbeStorageScope|undefined,options:ProbeStorageOptions,work:(sql:ProbeStorageSql,policy:ProbeStoragePolicy,bind:(value:ProbeStorageScope)=>Promise<void>)=>Promise<T>,mode:"default"|"expiry-write"="default"):Promise<T>=>{
|
||||
owner(ownerId);must(Number.isFinite(options.deadlineAt)&&options.deadlineAt>now())
|
||||
let authority:string|undefined
|
||||
const fence=async()=>{options.signal?.throwIfAborted();must(now()<options.deadlineAt);const proof=await deps.authorize(ownerId,operation,bound,options);must(hash.test(proof)&&(!authority||authority===proof));authority=proof;options.signal?.throwIfAborted();must(now()<options.deadlineAt)}
|
||||
await fence();let connection:ProbeStorageConnection
|
||||
try{connection=await deps.connect(options)}catch{throw new ProbeStorageUnknown()}
|
||||
let commitSent=false,committed=false,began=false,destroy=false
|
||||
const sql:ProbeStorageSql={query:async(text,values)=>{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. */
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user