Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac36a6ca5 | ||
|
|
cffcfada88 | ||
|
|
9240e8be11 | ||
|
|
d79fb3815a | ||
|
|
765b9a1658 | ||
|
|
5d181e7cb0 | ||
|
|
cf211f7418 | ||
|
|
04225886e0 | ||
|
|
1f0704add0 | ||
|
|
6569dbfc60 | ||
|
|
78b268cc39 | ||
|
|
3d5b7f0ea6 | ||
|
|
b4d2d4bc20 |
No files matched your search
@@ -924,18 +924,21 @@ export default function TrafficPage() {
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}
|
||||
void Promise.all([
|
||||
getTrafficFlows(backendUrl, range),
|
||||
getFlowExporters(backendUrl, range),
|
||||
getFlowClients(backendUrl, range),
|
||||
]).then(([stats, exporters, clients]) => {
|
||||
const loadFlowCards = async () => {
|
||||
// These reads share the collector's constrained admission budget.
|
||||
const stats = await getTrafficFlows(backendUrl, range)
|
||||
if (cancelled) return
|
||||
const exporters = await getFlowExporters(backendUrl, range)
|
||||
if (cancelled) return
|
||||
const clients = await getFlowClients(backendUrl, range)
|
||||
if (cancelled) return
|
||||
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
||||
setSelectedId(prev => {
|
||||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||||
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
|
||||
})
|
||||
}).catch(e => {
|
||||
}
|
||||
void loadFlowCards().catch(e => {
|
||||
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
|
||||
@@ -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}
|
||||
|
||||
+10
-1
@@ -1,7 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss" source(none);
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
/* Scan application sources only; local test archives and generated bundles
|
||||
must not become utility classes in the production stylesheet. */
|
||||
@source "./";
|
||||
@source "../components";
|
||||
@source "../entities";
|
||||
@source "../hooks";
|
||||
@source "../lib";
|
||||
@source "../shared";
|
||||
|
||||
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||
.network-map-focusable {
|
||||
outline: none;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Keep the live charge and intent fences in sync with recovery action v8.
|
||||
-- Existing authenticated v6/v7 history remains unchanged.
|
||||
BEGIN;
|
||||
CREATE OR REPLACE FUNCTION public.mcp_routing_manual_inverse_charge_source() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(
|
||||
SELECT 1 FROM public.mcp_operations o
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN public.mcp_routing_profiles r ON r.id=NEW.profile_id AND r.connection_id=o.connection_id
|
||||
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
|
||||
JOIN public.mcp_plans sp ON sp.id=source.plan_id AND sp.connection_id=source.connection_id
|
||||
JOIN public.mcp_connections c ON c.id=o.connection_id
|
||||
WHERE o.id=NEW.recovery_operation_id AND p.id=NEW.recovery_plan_id
|
||||
AND o.connection_id=NEW.connection_id AND c.owner_id=NEW.owner_id
|
||||
AND r.version=NEW.profile_version AND o.state='pending' AND p.state='executing'
|
||||
AND p.operation_id='operations.recover' AND p.operation_version=8
|
||||
AND sp.id=NEW.source_plan_id AND sp.operation_id='routing.community.switch' AND sp.operation_version=7
|
||||
AND source.state IN('applied','partial')
|
||||
) THEN RAISE EXCEPTION 'manual inverse charge differs from pending admission'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE OR REPLACE FUNCTION public.mcp_scoped_recovery_manual_version() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.mode='manual' AND NOT pg_try_advisory_xact_lock(hashtextextended('mcp-runner-admission',0))
|
||||
THEN RAISE EXCEPTION 'manual intent admission snapshot busy'; END IF;
|
||||
IF NEW.mode='manual' AND NOT EXISTS(
|
||||
SELECT 1 FROM public.mcp_operations o
|
||||
JOIN public.mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||
JOIN public.mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0
|
||||
JOIN public.mcp_operations source ON source.id=NEW.source_operation_id AND source.connection_id=o.connection_id
|
||||
WHERE o.id=NEW.recovery_operation_id AND o.state='running'
|
||||
AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=8
|
||||
AND NEW.source_step_index=0 AND s.state='intent'
|
||||
AND s.intended_state->>'kind'='service'
|
||||
AND s.intended_state->>'service'='routing.community.scoped.recover'
|
||||
AND s.intended_state->'input'->>'mode'='manual'
|
||||
AND s.intended_state->'input'->>'sourceOperationId'=NEW.source_operation_id::text
|
||||
AND s.intended_state->'input'->>'receiptId'=NEW.recovery_receipt_id::text
|
||||
) THEN RAISE EXCEPTION 'manual inverse requires current exact running recovery'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
INSERT INTO public.schema_migrations(id) VALUES('0056_mcp_manual_inverse_recovery_v8') ON CONFLICT(id) DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -597,6 +597,13 @@
|
||||
"operations": [],
|
||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/mcp/devices/:serverId/bridge-vlan/configuration",
|
||||
"status": "mediated",
|
||||
"operations": ["network.bridge-vlan.read"],
|
||||
"reason": "Чтение связанной конфигурации bridge/VLAN через общую типизированную модель и проверку доступа MCP."
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/mcp/drift",
|
||||
@@ -1611,6 +1618,13 @@
|
||||
"operations": [],
|
||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/mcp/devices/:serverId/bridge-vlan/configuration/plan",
|
||||
"status": "mediated",
|
||||
"operations": ["network.bridge-vlan.configure"],
|
||||
"reason": "Подготовка связанного плана bridge/VLAN через тот же MCP-инструмент; выполнение требует человеческого подтверждения."
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/mcp/management-channels",
|
||||
|
||||
@@ -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:wireguard": "tsx src/services/mikrotik-response-bounds.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: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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** External SDK consumer. No application/store/runtime/database imports. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
||||
|
||||
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT)
|
||||
assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
|
||||
assert.equal(endpoint.hostname, '127.0.0.1')
|
||||
assert.notEqual(process.pid, Number(process.env.OWN_SERVER_PID))
|
||||
const client = new Client({ name: 'owned-linked-bridge-vlan-sdk', version: '1.0.0' })
|
||||
const boundedFetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url)
|
||||
assert.equal(url.origin, endpoint.origin)
|
||||
return fetch(input, { ...init, redirect: 'manual', signal: AbortSignal.timeout(10000) })
|
||||
}
|
||||
const payload = result => {
|
||||
const text = result.content.find(item => item.type === 'text')?.text
|
||||
assert(text && Buffer.byteLength(text) < 131072)
|
||||
return JSON.parse(text)
|
||||
}
|
||||
const call = async (name, args) => {
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 10000 })
|
||||
assert(!result.isError, JSON.stringify(payload(result)))
|
||||
return payload(result)
|
||||
}
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(endpoint, {
|
||||
requestInit: { headers: { authorization: 'Bearer ' + process.env.OWN_MCP_TOKEN } }, fetch: boundedFetch,
|
||||
}), { timeout: 10000 })
|
||||
const tools = (await client.listTools({}, { timeout: 10000 })).tools
|
||||
const read = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_get')
|
||||
const configure = tools.find(tool => tool.name === 'mm_bridge_vlan_configuration_plan')
|
||||
assert(read && configure)
|
||||
assert.match(read.description, /Полная конфигурация bridge VLAN/)
|
||||
assert.match(read.description, /Возвращает данные только из разрешённой области/)
|
||||
assert.match(configure.description, /Связанный план VLAN membership, PVID и bridge filtering/)
|
||||
assert.match(configure.description, /Подготавливает неизменяемый план/)
|
||||
assert.equal(read.annotations.readOnlyHint, true)
|
||||
assert.equal(configure.annotations.readOnlyHint, false)
|
||||
assert.deepEqual(read.inputSchema.required, ['serverId'])
|
||||
assert.deepEqual(configure.inputSchema.required, ['serverId', 'bridgeId', 'desired'])
|
||||
const desiredSchema = configure.inputSchema.properties.desired
|
||||
for (const name of ['ports', 'vlans', 'removePortIds', 'removeVlanIds']) assert(desiredSchema.properties[name])
|
||||
const serverId = Number(process.env.OWN_ALLOWED_SERVER)
|
||||
const state = (await call(read.name, { serverId })).state
|
||||
assert.equal(state.bridges[0]['.id'], '*1')
|
||||
assert(state.interfaces.some(item => item.resource === 'ethernet' && item.id === '*3' && item.name === 'ether3'))
|
||||
assert.equal(state.vlans[0]['vlan-ids'], '10')
|
||||
const cpu = { resource: 'bridge', id: '*1' }, access = { resource: 'ethernet', id: '*2' }, trunk = { resource: 'ethernet', id: '*3' }
|
||||
const plan = await call(configure.name, { serverId, bridgeId: '*1', desired: {
|
||||
filtering: true, pvid: 1,
|
||||
ports: [{ id: '*10', interface: access, pvid: 10, ingressFiltering: true, frameTypes: 'admit-all', disabled: false },
|
||||
{ interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: 'admit-only-vlan-tagged', disabled: false }],
|
||||
vlans: [{ id: '*11', vlanIds: '10', tagged: [cpu], untagged: [access], disabled: false },
|
||||
{ vlanIds: '20', tagged: [cpu, trunk], untagged: [], disabled: false }], removePortIds: [], removeVlanIds: [],
|
||||
} })
|
||||
assert.equal(plan.state, 'awaiting-approval')
|
||||
assert.equal(typeof plan.planId, 'string')
|
||||
assert.equal(plan.changes.requiresHuman, true)
|
||||
assert.deepEqual(plan.changes.steps.map(item => [item.step.resource, item.step.action]),
|
||||
[['bridge_vlan', 'create'], ['bridge_port', 'create'], ['bridge', 'update']])
|
||||
const denied = await client.callTool({ name: 'mm_change_apply', arguments: { planId: plan.planId, idempotencyKey: randomUUID() } }, undefined, { timeout: 10000 })
|
||||
assert.equal(denied.isError, true)
|
||||
const deniedPayload = payload(denied)
|
||||
assert(JSON.stringify(deniedPayload).includes('APPROVAL_REQUIRED'))
|
||||
const readback = (await call(read.name, { serverId })).state
|
||||
assert.deepEqual(readback, state)
|
||||
process.send({ kind: 'result', result: { externalSdkProcess: true, transport: 'streamable-http', clientPid: process.pid,
|
||||
listedTools: [read, configure], state, plan, applyDenial: deniedPayload, unchangedReadback: true } })
|
||||
} finally {
|
||||
await client.close()
|
||||
process.disconnect?.()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
|
||||
assert.equal(process.env.OWN_EXTERNAL_PROCESS_PG, 'true')
|
||||
assert.equal(process.env.NODE_ENV, 'test')
|
||||
assert(isAbsolute(process.env.OWN_EXTERNAL_SOURCE_ROOT!))
|
||||
assert(isAbsolute(process.env.OWN_EXTERNAL_RESULT_FILE!))
|
||||
const database = new URL(process.env.DATABASE_URL!)
|
||||
assert.equal(database.hostname, '127.0.0.1'); assert.equal(database.username, 'mcp_test')
|
||||
assert.equal(database.pathname, '/postgres'); assert.notEqual(database.port, '5432')
|
||||
const source = (path: string) => pathToFileURL(resolve(process.env.OWN_EXTERNAL_SOURCE_ROOT!, 'backend/src', path)).href
|
||||
const rows: Record<string, Record<string, string>[]> = {
|
||||
'/interface/ethernet': [{ '.id': '*2', name: 'ether2', disabled: 'false' }, { '.id': '*3', name: 'ether3', disabled: 'false' }, { '.id': '*4', name: 'ether-mgmt', disabled: 'false' }],
|
||||
'/interface/bridge': [{ '.id': '*1', name: 'br-users', pvid: '1', 'vlan-filtering': 'false', 'frame-types': 'admit-all', 'ingress-filtering': 'true', mvrp: 'false', disabled: 'false' }],
|
||||
'/interface/bridge/port': [{ '.id': '*10', bridge: 'br-users', interface: 'ether2', pvid: '10', 'ingress-filtering': 'true', 'frame-types': 'admit-all', disabled: 'false' }],
|
||||
'/interface/bridge/vlan': [{ '.id': '*11', bridge: 'br-users', 'vlan-ids': '10', tagged: 'br-users', untagged: 'ether2', disabled: 'false', dynamic: 'false' }],
|
||||
'/interface/vlan': [], '/interface/gre': [], '/interface/wireguard': [], '/interface/vxlan': [],
|
||||
'/ip/address': [{ '.id': '*20', interface: 'ether-mgmt', address: '127.0.0.1/8', disabled: 'false', dynamic: 'false' }], '/ipv6/address': [],
|
||||
}
|
||||
const requests: string[] = []
|
||||
let writeAttempts = 0
|
||||
const router = createServer((req, res) => {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
const path = decodeURIComponent(new URL(req.url!, 'http://127.0.0.1').pathname.replace(/^\/rest/, ''))
|
||||
requests.push(req.method + ' ' + path)
|
||||
if (req.method !== 'GET') { writeAttempts++; res.writeHead(405); res.end('{}'); return }
|
||||
if (req.headers.authorization !== 'Basic ' + Buffer.from('fixture:fixture-only').toString('base64')) { res.writeHead(401); res.end('{}'); return }
|
||||
if (!Object.hasOwn(rows, path)) { res.writeHead(404); res.end('{}'); return }
|
||||
res.end(JSON.stringify(rows[path]))
|
||||
})
|
||||
await new Promise<void>(done => router.listen(0, '127.0.0.1', done))
|
||||
const { pool, closePool } = await import(source('db/index.js'))
|
||||
const { applySqlMigrations } = await import(source('db/migrate.js'))
|
||||
const { mcpConnectionCreateSchema } = await import('@mmapp/contracts/mcp')
|
||||
const { createMcpConnection, updateMcpSettings } = await import(source('modules/mcp/store.js'))
|
||||
const { buildApp } = await import(source('index.js'))
|
||||
const actor = { id: 'owned-bridge-vlan-sdk-owner', email: 'bridge-sdk@example.invalid', name: 'Owned bridge VLAN SDK owner', apps: ['mm'], permissions: ['mm:settings:admin', 'mm:mcp:admin', 'mm:network:admin', 'mm:servers:admin'], expiresAt: Date.now() + 3600000, issuedAt: Date.now() - 1000 }
|
||||
let app: Awaited<ReturnType<typeof buildApp>> | undefined
|
||||
let child: ReturnType<typeof spawn> | undefined
|
||||
let childExit: Promise<number> | undefined
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
const serverId = (await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('owned bridge VLAN SDK','127.0.0.1',$1,false,true,'fixture','fixture-only') RETURNING id", [(router.address() as { port: number }).port])).rows[0].id
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
app = await buildApp({ startScheduler: false, logger: false })
|
||||
await app.listen({ host: '127.0.0.1', port: 0 })
|
||||
const endpoint = `http://127.0.0.1:${(app.server.address() as { port: number }).port}/mcp`
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: 'Owned bridge VLAN external SDK', mode: 'autonomous',
|
||||
expiresAt: new Date(Date.now() + 1200000).toISOString(), permissions: ['mm:network:write'],
|
||||
scope: { serverIds: [serverId], operationIds: ['network.bridge-vlan.read', 'network.bridge-vlan.configure', 'changes.apply'], allowGlobal: false },
|
||||
limits: { callsPerMinute: 120, mutationsPerHour: 100 } }), actor)
|
||||
let result: unknown, stdout = '', stderr = ''
|
||||
child = spawn(process.execPath, [fileURLToPath(new URL('./bridge-vlan-client.mjs', import.meta.url))], { cwd: process.cwd(), windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'], env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
||||
OWN_EXTERNAL_CLIENT: 'true', OWN_MCP_ENDPOINT: endpoint, OWN_MCP_TOKEN: issued.token, OWN_SERVER_PID: String(process.pid), OWN_ALLOWED_SERVER: String(serverId) } })
|
||||
child.on('message', (message: { kind: string; result?: unknown }) => { if (message.kind === 'result') result = message.result })
|
||||
child.stdout?.on('data', chunk => { stdout += String(chunk); if (stdout.length > 1048576) child?.kill() })
|
||||
child.stderr?.on('data', chunk => { stderr += String(chunk); if (stderr.length > 1048576) child?.kill() })
|
||||
childExit = new Promise<number>((done, reject) => { child!.once('exit', code => done(code ?? 1)); child!.once('error', reject) })
|
||||
const timeout = setTimeout(() => child?.kill(), 60000)
|
||||
const code = await childExit; clearTimeout(timeout)
|
||||
assert.equal(code, 0, stderr.slice(0, 3000)); assert(result)
|
||||
assert.equal(writeAttempts, 0)
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS count FROM mcp_operations')).rows[0].count, 0)
|
||||
const plans = (await pool.query('SELECT operation_id,state,approved_by FROM mcp_plans')).rows
|
||||
assert.equal(plans.length, 1); assert.equal(plans[0].operation_id, 'network.bridge-vlan.configure')
|
||||
assert.equal(plans[0].state, 'awaiting-approval'); assert.equal(plans[0].approved_by, null)
|
||||
const audit = (await pool.query('SELECT tool,status,error_code FROM mcp_requests ORDER BY created_at')).rows
|
||||
assert(audit.some(row => row.error_code === 'APPROVAL_REQUIRED'))
|
||||
const serialized = JSON.stringify({ result, stdout, stderr, audit })
|
||||
assert(!serialized.includes(issued.token)); assert(!serialized.includes('fixture-only'))
|
||||
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
||||
connectionMode: 'autonomous', plans, audit, nativeReadRequests: requests, nativeWriteAttempts: writeAttempts,
|
||||
appliedOperations: 0, syntheticRouterOnly: true, productionDatabaseTouched: false, portalContacted: false }
|
||||
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||
console.log('Owned bridge VLAN SDK passed:', JSON.stringify({ listedTools: 2, planState: plans[0].state, nativeReadRequests: requests.length, nativeWriteAttempts: writeAttempts }))
|
||||
} finally {
|
||||
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await childExit }
|
||||
try { await app?.close() } finally { try { await closePool() } finally { router.closeIdleConnections(); await new Promise<void>((done, reject) => router.close(error => error ? reject(error) : done())) } }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -6,10 +6,16 @@ import { randomBytes, createHash } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url)))
|
||||
const output = resolve(sourceRoot, 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
|
||||
const scenario = process.argv[3] ?? 'acceptance'
|
||||
if (!['acceptance', 'bridge-vlan'].includes(scenario)) throw Error('Unsupported owned external scenario')
|
||||
const output = resolve(sourceRoot, scenario === 'bridge-vlan' ? 'tmp/mcp-bridge-vlan-sdk-20261105' : 'tmp/mcp-external-process-acceptance'); await mkdir(output, { recursive: true })
|
||||
const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts',
|
||||
'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts',
|
||||
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
|
||||
if (scenario === '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)
|
||||
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
|
||||
@@ -19,7 +25,7 @@ const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port
|
||||
try {
|
||||
await postgres.initialise(); await postgres.start()
|
||||
process.exitCode = await new Promise((done, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('./server-fixture.ts', import.meta.url))], { cwd: directory,
|
||||
const child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL(scenario === 'bridge-vlan' ? './bridge-vlan-fixture.ts' : './server-fixture.ts', import.meta.url))], { cwd: directory,
|
||||
windowsHide: true, stdio: 'inherit', env: { ...Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH', 'Path'].filter(k => process.env[k]).map(k => [k, process.env[k]])),
|
||||
OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT, OWN_EXTERNAL_SOURCE_ROOT: sourceRoot, OWN_EXTERNAL_RESULT_FILE: join(output, 'result.json'), NODE_ENV: 'test', OWN_EXTERNAL_PROCESS_PG: 'true', DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`, DATABASE_PATH: join(directory, 'no-legacy.sqlite'),
|
||||
MCP_STATE_DIR: state, AUTH_REQUIRED: 'true', AUTH_JWT_SECRET: 'owned-external-synthetic-issuer-test-only', AUTH_ISSUER: 'https://synthetic-portal.invalid', AUTH_PORTAL_URL: 'https://synthetic-portal.invalid' } })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import { createServer } from "node:net"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
@@ -19,6 +20,7 @@ const { pool, closePool } = await import(source("db/index.js"))
|
||||
const { applySqlMigrations } = await import(source("db/migrate.js"))
|
||||
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
|
||||
const { createMcpConnection, rotateConnectionToken, setConnectionStatus, updateMcpSettings } = await import(source("modules/mcp/store.js"))
|
||||
const { listActions } = await import(source("modules/mcp/actions.js")) as typeof import("../../src/modules/mcp/actions.js")
|
||||
const { env } = await import(source("config.js"))
|
||||
const { buildApp } = await import(source("index.js"))
|
||||
const actor = { id: "owned-external-process-owner", email: "external@example.invalid", name: "Owned external SDK fixture", apps: ["mm"],
|
||||
@@ -27,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") })]) }
|
||||
@@ -46,9 +68,73 @@ 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"
|
||||
const registration = await fetch(origin + "/api/mcp/oauth/clients", { method: "POST", headers: portalHeaders,
|
||||
body: JSON.stringify({ name: "Owned denial fixture", redirectUris: [callback] }) })
|
||||
assert.equal(registration.status, 200)
|
||||
const { clientId } = await registration.json() as { clientId: string }
|
||||
const consent = (payload: Record<string, unknown>, headers = portalHeaders) => fetch(origin + "/api/mcp/oauth/consent", { method: "POST", headers, body: JSON.stringify(payload) })
|
||||
const authorize = async (state: string) => {
|
||||
const parameters = new URLSearchParams({ client_id: clientId, redirect_uri: callback, response_type: "code", resource: endpoint,
|
||||
code_challenge_method: "S256", code_challenge: createHash("sha256").update("v".repeat(64)).digest("base64url"), state })
|
||||
const response = await fetch(`${origin}/oauth/authorize?${parameters}`, { redirect: "manual" })
|
||||
assert.equal(response.status, 302)
|
||||
const requestId = new URL(response.headers.get("location")!).searchParams.get("oauthRequest")
|
||||
assert.ok(requestId)
|
||||
return requestId
|
||||
}
|
||||
const requestId = await authorize("deny-without-connection")
|
||||
assert.equal((await consent({ requestId, approve: false }, { ...portalHeaders, authorization: "" })).status, 401)
|
||||
assert.equal((await consent({ requestId, approve: true })).status, 400)
|
||||
const refusals = await Promise.all([consent({ requestId, approve: false }), consent({ requestId, approve: false })])
|
||||
assert.deepEqual(refusals.map(response => response.status).sort(), [200, 404])
|
||||
const { redirectUrl } = await refusals.find(response => response.status === 200)!.json() as { redirectUrl: string }
|
||||
const redirect = new URL(redirectUrl)
|
||||
const expected = new URL(callback)
|
||||
expected.searchParams.set("state", "deny-without-connection")
|
||||
expected.searchParams.set("iss", origin)
|
||||
expected.searchParams.set("error", "access_denied")
|
||||
assert.equal(redirect.href, expected.href)
|
||||
const otherOwner = { ...actor, id: "owned-external-process-other-owner" }
|
||||
const unrelated = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Unrelated paused connection", mode: "observer",
|
||||
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
|
||||
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), otherOwner)
|
||||
await setConnectionStatus(unrelated.connection.id, "paused", otherOwner)
|
||||
const legacyRequestId = await authorize("legacy-denial")
|
||||
const legacy = await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })
|
||||
assert.equal(legacy.status, 200)
|
||||
assert.equal((await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })).status, 404)
|
||||
const expired = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Expired connection", mode: "observer",
|
||||
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
|
||||
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), actor)
|
||||
await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1", [expired.connection.id, "2000-01-01T00:00:00Z"])
|
||||
const expiredRequestId = await authorize("expired-approval")
|
||||
assert.equal((await consent({ requestId: expiredRequestId, connectionId: expired.connection.id, approve: true })).status, 403)
|
||||
const expiredRequest = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE id=$1", [expiredRequestId])).rows[0]
|
||||
assert.deepEqual(expiredRequest, { consumed_at: null, code_digest: null, connection_id: null })
|
||||
assert.equal((await consent({ requestId: expiredRequestId, approve: false })).status, 200)
|
||||
const deniedRows = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE client_id=$1", [clientId])).rows
|
||||
assert.equal(deniedRows.length, 3)
|
||||
assert(deniedRows.every(row => row.consumed_at instanceof Date && row.code_digest === null && row.connection_id === null))
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_credentials WHERE client_id=$1", [clientId])).rows[0].count, 0)
|
||||
const denialAudit = (await pool.query("SELECT actor_id,connection_id,payload FROM mcp_audit WHERE action='oauth.consent' AND payload->>'clientId'=$1", [clientId])).rows
|
||||
assert.equal(denialAudit.length, 3)
|
||||
assert(denialAudit.every(row => row.actor_id === actor.id && row.connection_id === null && row.payload.approved === false))
|
||||
console.log("OAuth consent: expired approval refused before code issuance, denial uses exact redirect and one-use request without credentials")
|
||||
const transcript: unknown[] = []; let failure: unknown, result: unknown
|
||||
child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
||||
@@ -67,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 }
|
||||
@@ -88,11 +295,30 @@ 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, nativeRouterCalls: 0, productionDatabaseTouched: false }
|
||||
controlTransitions: transcript, auditCredentialLeakAbsent: true, denialWithoutConnection: true,
|
||||
legacyDenialIgnoresPausedForeignConnection: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
|
||||
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||
console.log("Owned external process SDK result:", JSON.stringify(final))
|
||||
} finally {
|
||||
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'))
|
||||
|
||||
@@ -38,6 +38,7 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n
|
||||
if (Array.isArray(value.accepted)) { row.finalCaseCount = value.accepted.length; if (expectedSlice !== null && value.slice === expectedSlice) finalComponentPresent = true }
|
||||
if (['planned','queued','running','applied','failed','cancelled','unknown','expired','rejected','unconfirmed'].includes(value.operationState)) row.operationState = value.operationState
|
||||
for (const key of ['phase', 'elapsedMs', 'nativeReads', 'nativeWrites', 'roundTrips', 'lastSqlMs', 'totalSqlMs', 'maxSqlMs']) if (Number.isSafeInteger(value[key]) && value[key] >= 0) row[key] = value[key]
|
||||
if (Number.isSafeInteger(value.wireguardPeerTelemetryTicks) && value.wireguardPeerTelemetryTicks > 0 && value.wireguardPeerTelemetryTicks <= 10000) row.wireguardPeerTelemetryTicks = value.wireguardPeerTelemetryTicks
|
||||
if (value.diagnosticWaitSql === true) row.diagnosticWaitSql = true
|
||||
if (value.diagnosticError === true) { row.diagnosticError = true; row.safeName = diagnosticNames.has(value.safeName) ? value.safeName : 'other'; row.safeCode = diagnosticCodes.has(value.safeCode) ? value.safeCode : 'other' }
|
||||
if (!Object.keys(row).length) { omittedLines++; continue }
|
||||
@@ -47,10 +48,10 @@ export function projectVrfDiagnostics(log, privateValues = [], expectedSlice = n
|
||||
}
|
||||
export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) {
|
||||
assert(result && typeof result === 'object')
|
||||
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias'].includes(result.slice))
|
||||
assert(['baseline','physical','vlan','disabled','mixed','reconcile','sql-journal','guards','coexistence','alias','gre'].includes(result.slice))
|
||||
assert(Number.isSafeInteger(result.code) && typeof result.childExitConfirmed === 'boolean')
|
||||
assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0)
|
||||
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : 120000)
|
||||
assert.equal(childBudgetMs, result.slice === 'baseline' ? 240000 : result.slice === 'disabled' ? 180000 : 120000)
|
||||
assert(result.failure === null || ['child-deadline','child-log-limit','child-signal','child-error','child-error-after-pid','child-exit-unconfirmed','missing-component-receipt'].includes(result.failure))
|
||||
assert(result.accepted === (result.code === 0 && result.failure === null && result.childExitConfirmed))
|
||||
return { ...result, capMs: childBudgetMs, diagnostics: projectVrfDiagnostics(log, privateValues, result.slice), scope: 'same-owned-PG-test-only', actualRouter: false, forwardingVerified: false }
|
||||
@@ -88,10 +89,14 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
|
||||
const report = fileURLToPath(new URL('../../docs/mcp-chr-vrf-companion-native-v5.json', import.meta.url))
|
||||
const loader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href
|
||||
const safe = Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL'].filter(key => typeof process.env[key] === 'string').map(key => [key, process.env[key]]))
|
||||
for (const slice of ['baseline', 'physical', 'vlan', 'disabled', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
|
||||
// Diagnose the disabled lifecycle first, then the synthetic GRE extension.
|
||||
// Every original case remains required by the same full74 gate and caps.
|
||||
for (const slice of ['disabled', 'gre', 'baseline', 'physical', 'vlan', 'mixed', 'reconcile', 'sql-journal', 'guards', 'coexistence', 'alias']) {
|
||||
currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null
|
||||
await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false })
|
||||
const childBudgetMs = slice === 'baseline' ? 240000 : 120000
|
||||
// Nine disabled lifecycle operations need their own aggregate child cap;
|
||||
// measured seven operations alone consumed 105.5s under the original120s.
|
||||
const childBudgetMs = slice === 'baseline' ? 240000 : slice === 'disabled' ? 180000 : 120000
|
||||
const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd)
|
||||
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
|
||||
assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink())
|
||||
@@ -121,9 +126,10 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
|
||||
unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000)
|
||||
})
|
||||
clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer)
|
||||
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4 }
|
||||
const expectedCases = { baseline: 11, physical: 7, vlan: 9, disabled: 9, mixed: 7, reconcile: 6, 'sql-journal': 1, guards: 11, coexistence: 3, alias: 4, gre: 6 }
|
||||
const component = log.trim().split(/\r?\n/).flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }).findLast(value => value.slice === slice && Array.isArray(value.accepted))
|
||||
if (!component || component.accepted.length !== expectedCases[slice] || component.actualSql !== true || component.actualCrypto !== true || component.actualAuthPolicyApprovalRuntime !== true || component.fakeVerifier !== false || component.actualRouter !== false || component.forwardingVerified !== false) failure ??= 'missing-component-receipt'
|
||||
if (slice === 'gre' && (!Number.isSafeInteger(component?.wireguardPeerTelemetryTicks) || component.wireguardPeerTelemetryTicks <= 6 || component.wireguardPeerTelemetryTicks > 10000)) failure ??= 'missing-component-receipt'
|
||||
const result = { slice, accepted: code === 0 && !failure && exited, caseCount: component?.accepted?.length ?? 0, code, failure, childPid: child.pid ?? null, childExitConfirmed: exited }
|
||||
lastSliceResult = result; currentChildExitConfirmed = exited
|
||||
const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret])
|
||||
@@ -135,8 +141,8 @@ export async function runOwnedVrfProductionProfile({ directory, database, databa
|
||||
console.log(JSON.stringify({ profile: 'test:vrf:production', ...result }))
|
||||
if (!result.accepted) break
|
||||
}
|
||||
const receipt = { accepted: results.length === 10 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
|
||||
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 68, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
|
||||
const receipt = { accepted: results.length === 11 && results.every(result => result.accepted), childExitConfirmed: results.every(result => result.childExitConfirmed), sameOwnedPg: true, actualRouter: false, forwardingVerified: false, results }
|
||||
await persist('terminal.json', { version: 1, status: 'terminal', ...receipt, requiredCases: 74, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
|
||||
await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2))
|
||||
return receipt
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { vrfSliceDiagnostic } from './vrf-production-profile.mjs'
|
||||
|
||||
test('only the nine-operation disabled slice receives its measured aggregate budget', () => {
|
||||
for (const [slice, capMs] of [['disabled', 180000], ['gre', 120000], ['baseline', 240000]]) {
|
||||
const result = { slice, accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true }
|
||||
assert.equal(vrfSliceDiagnostic(result, '', capMs).capMs, capMs)
|
||||
for (const otherCap of [120000, 180000, 240000].filter(value => value !== capMs)) assert.throws(() => vrfSliceDiagnostic(result, '', otherCap))
|
||||
}
|
||||
})
|
||||
|
||||
test('GRE lifecycle diagnostic retains the six-case receipt with existing privacy bounds', () => {
|
||||
const accepted = ['vrf-create', 'vrf-rename', 'vrf-disable', 'vrf-reenable', 'vrf-membership-transfer-a-to-b', 'vrf-delete-addresses-remain']
|
||||
const result = { slice: 'gre', accepted: true, caseCount: 6, code: 0, failure: null, childExitConfirmed: true }
|
||||
const log = accepted.map((acceptedCase, index) => JSON.stringify({ slice: 'gre', acceptedCase, nativeWrites: 1, wireguardPeerTelemetryTicks: (index + 1) * 4, password: 'fixture-private-value' }))
|
||||
log.push(JSON.stringify({ slice: 'gre', accepted, wireguardPeerTelemetryTicks: 24, rawShape: 'synthetic GRE lifecycle', password: 'fixture-private-value' }))
|
||||
const diagnostic = vrfSliceDiagnostic(result, log.join('\n'), 120000, ['fixture-private-value'])
|
||||
assert.deepEqual(diagnostic.diagnostics.acceptedCaseSequence, accepted)
|
||||
assert.equal(diagnostic.diagnostics.finalComponentPresent, true)
|
||||
assert.equal(diagnostic.diagnostics.rawLogRetained, false)
|
||||
assert.deepEqual(diagnostic.diagnostics.records.map(row => row.wireguardPeerTelemetryTicks), [4, 8, 12, 16, 20, 24, 24])
|
||||
assert.equal(diagnostic.actualRouter, false)
|
||||
assert.equal(diagnostic.forwardingVerified, false)
|
||||
assert.equal(JSON.stringify(diagnostic).includes('fixture-private-value'), false)
|
||||
assert.throws(() => vrfSliceDiagnostic(result, '', 240000))
|
||||
})
|
||||
|
||||
test('peer telemetry diagnostic rejects nonnumeric and out-of-budget values', () => {
|
||||
const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'missing-component-receipt', childExitConfirmed: true }
|
||||
for (const wireguardPeerTelemetryTicks of [0, -1, 10001, 1.5, 'private-raw-value', { secret: 'private-raw-value' }]) {
|
||||
const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', wireguardPeerTelemetryTicks }), 120000)
|
||||
assert.equal(diagnostic.diagnostics.records.some(row => Object.hasOwn(row, 'wireguardPeerTelemetryTicks')), false)
|
||||
assert.equal(JSON.stringify(diagnostic).includes('private-raw-value'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('a partial GRE lifecycle is diagnostic evidence without a final component receipt', () => {
|
||||
const result = { slice: 'gre', accepted: false, caseCount: 0, code: 1, failure: 'child-deadline', childExitConfirmed: true }
|
||||
const diagnostic = vrfSliceDiagnostic(result, JSON.stringify({ slice: 'gre', acceptedCase: 'vrf-create' }), 120000)
|
||||
assert.equal(diagnostic.accepted, false)
|
||||
assert.equal(diagnostic.diagnostics.finalComponentPresent, false)
|
||||
assert.equal(diagnostic.diagnostics.lastAcceptedCase, 'vrf-create')
|
||||
assert.equal(diagnostic.capMs, 120000)
|
||||
})
|
||||
@@ -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})}
|
||||
})
|
||||
@@ -12,10 +12,10 @@ const reviewed = (source: string, extra: Partial<MigrationReview> = {}) => {
|
||||
const bytes = Buffer.from(source), file = "9000_fixture.sql"
|
||||
return () => analyzeMigration(file, bytes, { file, sha256: createHash("sha256").update(bytes).digest("hex"), transaction: "owner-transaction", selfMarker: false, opaqueBodies: 0, bodyReview: [], effects: ["Explicit inert fixture review"], ...extra })
|
||||
}
|
||||
await test("all 55 exact file pins and every top-level schema operation are inventoried", () => {
|
||||
assert.equal(inventory.length, 55); assert.equal(MIGRATION_MANIFEST.length, 55)
|
||||
await test("all 56 exact file pins and every top-level schema operation are inventoried", () => {
|
||||
assert.equal(inventory.length, 56); assert.equal(MIGRATION_MANIFEST.length, 56)
|
||||
assert.equal(inventory.filter(file => file.transaction === "owner-transaction").length, 17)
|
||||
assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 38)
|
||||
assert.equal(inventory.filter(file => file.transaction === "self-managed-single-transaction").length, 39)
|
||||
assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
|
||||
let tables = new Set<string>()
|
||||
for (const file of inventory) {
|
||||
@@ -32,8 +32,8 @@ await test("DO BEGIN and stored function BEGIN are never transaction boundaries"
|
||||
assert.equal(endpoint.topLevelTransactionControls.length, 2); assert.equal(endpoint.opaqueBodies.length, 2)
|
||||
assert.deepEqual(endpoint.opaqueBodies.map(body => body.kind), ["stored-function-body", "executed-do-body"])
|
||||
})
|
||||
await test("0035 through 0055 have exact atomic file-owned terminal markers", () => {
|
||||
assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement"])
|
||||
await test("0035 through 0056 have exact atomic file-owned terminal markers", () => {
|
||||
assert.deepEqual(inventory.filter(file => file.selfMarker).map(file => file.id), ["0035_server_probe_journal", "0036_diagnostic_cancellations", "0037_server_probe_storage_v2", "0038_server_poll_journal_and_writer_guards", "0039_server_poll_storage", "0040_mcp_routing_reservations", "0041_server_poll_terminal_archives", "0042_server_poll_settings_epoch", "0043_mcp_routing_forward_charges", "0044_mcp_routing_manual_inverse_charges", "0045_mcp_operation_replay_tombstones", "0046_server_poll_operation_retirement", "0047_server_poll_retirement_claims", "0048_mcp_ordinary_retirement_claims", "0049_mcp_ordinary_telemetry_retirement", "0050_server_probe_retired_sources", "0051_server_probe_retirement_claims", "0052_server_probe_retired_gc", "0053_server_probe_terminal_proofs", "0054_mcp_geoip_retirement", "0055_mcp_backup_schedule_retirement", "0056_mcp_manual_inverse_recovery_v8"])
|
||||
for (const file of inventory.filter(file => file.selfMarker)) { assert.equal(file.selfMarker!.atomicWithAllFileEffects, true); assert.equal(file.selfMarker!.statement, file.statementCount - 2); assert.equal(file.currentOuterMarkerGap, false) }
|
||||
assert.equal(inventory.filter(file => file.currentOuterMarkerGap).length, 34)
|
||||
})
|
||||
@@ -228,4 +228,20 @@ await test("0044 adds only a fixed manual inverse ledger and current-version int
|
||||
assert(!pin.operations.some(operation => operation.command === "UPDATE" || operation.command === "DELETE"))
|
||||
})
|
||||
|
||||
await test("0056 changes only live manual inverse recovery version guards", async () => {
|
||||
const migration = inventory.find(file => file.id === "0056_mcp_manual_inverse_recovery_v8")!
|
||||
assert.equal(migration.statementCount, 5)
|
||||
assert.equal(migration.opaqueBodies.length, 2)
|
||||
assert.equal(migration.selfMarker?.atomicWithAllFileEffects, true)
|
||||
const oldSql = await readFile(new URL("../../drizzle/0044_mcp_routing_manual_inverse_charges.sql", import.meta.url), "utf8")
|
||||
const newSql = await readFile(new URL("../../drizzle/0056_mcp_manual_inverse_recovery_v8.sql", import.meta.url), "utf8")
|
||||
for (const name of ["mcp_routing_manual_inverse_charge_source", "mcp_scoped_recovery_manual_version"]) {
|
||||
const definition = new RegExp(`CREATE(?: OR REPLACE)? FUNCTION public\\.${name}\\(\\)[\\s\\S]*?END \\$\\$;`)
|
||||
const original = oldSql.match(definition)?.[0]?.replaceAll("\r\n", "\n")
|
||||
assert.ok(original)
|
||||
assert.equal(newSql.match(definition)?.[0]?.replaceAll("\r\n", "\n"), original.replace("CREATE FUNCTION", "CREATE OR REPLACE FUNCTION")
|
||||
.replace("p.operation_id='operations.recover' AND p.operation_version=7", "p.operation_id='operations.recover' AND p.operation_version=8"))
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`)
|
||||
@@ -854,6 +854,20 @@ const classifiedMigrationPins: readonly MigrationReview[] = [
|
||||
"effects": [
|
||||
"Replaces the ordinary claim guard function without changing backup settings, metadata or entries during migration."
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "0056_mcp_manual_inverse_recovery_v8.sql",
|
||||
"sha256": "1dece1d9c5a9cf69e51336f35f868561dad9da6d21556ea31ef5b102634ed9ee",
|
||||
"transaction": "self-managed-single-transaction",
|
||||
"selfMarker": true,
|
||||
"opaqueBodies": 2,
|
||||
"bodyReview": [
|
||||
"The manual inverse charge-source guard requires recovery version 8 and preserves its pending operation, source version, owner and profile checks.",
|
||||
"The manual inverse intent guard requires recovery version 8 and preserves its advisory lock and exact running service intent checks."
|
||||
],
|
||||
"effects": [
|
||||
"Replaces the two live version guard functions and preserves existing charges, intents, and plans."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ConnectionRow, McpPrincipal } from "./store.js"
|
||||
import type { RosSnapshot, RosStep } from "./resources.js"
|
||||
|
||||
export interface ActionContext {
|
||||
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal
|
||||
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal; logsFollowMaintenance?: AbortSignal
|
||||
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
||||
persistentRoutingProfile?: { id: string; version: number }
|
||||
/** Available only during target resolution, never retained by the read/prepare handler. */
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,15 +9,17 @@ import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-
|
||||
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
||||
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
||||
import { isIP } from "node:net"
|
||||
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 } 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"
|
||||
@@ -29,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"
|
||||
@@ -77,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"
|
||||
@@ -104,6 +106,7 @@ import { registerRecursiveRecovery } from "./recursive-recovery.js"
|
||||
import { registerNetworkHostActions } from "./network-host-actions.js"
|
||||
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
|
||||
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-actions.js"
|
||||
import { registerBridgeVlanActions, prepareGenericBridgeVlanChange } from "./bridge-vlan-actions.js"
|
||||
|
||||
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||
const rowId = z.string().trim().min(1).max(200)
|
||||
@@ -127,6 +130,7 @@ registerRecursiveRecovery(add)
|
||||
registerNetworkHostActions(add)
|
||||
registerBgpFilterBindingActions(add)
|
||||
registerBgpCommunityPolicyActions(add)
|
||||
registerBridgeVlanActions(add)
|
||||
registerTrafficHistoryActions(add)
|
||||
registerSidebarCountsActions(add)
|
||||
registerLogArtifactActions(add)
|
||||
@@ -152,9 +156,9 @@ 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" ? 1 : 0),
|
||||
version: resource.id === "ipsec_peer" ? (action === "create" ? 5 : 3) : resource.id === "certificate" ? (action === "update" ? 4 : 3) : previousVersion + (delegated ? 0 : 1) + (resource.id === "ospf_template" ? 1 : 0) + (resource.id === "vrf" ? (action === "delete" ? 3 : 2) : 0) + (["bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 1 : 0),
|
||||
prepare: async (input, context) => {
|
||||
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
|
||||
const sid = Number(input.serverId), id = input.id as string | undefined
|
||||
@@ -191,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")
|
||||
@@ -250,13 +256,24 @@ for (const resource of rosResources) {
|
||||
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
|
||||
}
|
||||
}
|
||||
const linkedVlan = await prepareGenericBridgeVlanChange({ serverId: sid, resource: resource.id, action, id, fields }, context)
|
||||
if (linkedVlan) {
|
||||
const captured = new Map(snapshots.map(snapshot => [snapshot.resource, snapshot]))
|
||||
for (const snapshot of linkedVlan.snapshots) {
|
||||
const previous = captured.get(snapshot.resource)
|
||||
if (previous && (previous.fingerprint !== snapshot.fingerprint || previous.sourceProof !== snapshot.sourceProof)) throw new McpError("STATE_CONFLICT", "Зависимости VLAN изменились во время подготовки", 409)
|
||||
captured.set(snapshot.resource, snapshot)
|
||||
}
|
||||
const objects = new Map([...references, ...linkedVlan.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object]))
|
||||
return { ...linkedVlan, snapshots: [...captured.values()], objects: [...objects.values()] }
|
||||
}
|
||||
const placeBeforeId = resource.id === "vrf" && action === "create" ? before.rows[0]?.[".id"] : undefined
|
||||
if (resource.id === "vrf" && action === "create" && typeof placeBeforeId !== "string") throw new McpError("STATE_CONFLICT", "Не подтверждён порядок существующих VRF", 409)
|
||||
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
|
||||
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
|
||||
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
|
||||
requiresHuman: true,
|
||||
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
||||
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(rpf?.warnings ?? []), ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -268,7 +285,7 @@ for (const definition of [
|
||||
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
|
||||
] as const) {
|
||||
add(definition.id, definition.tool, definition.title, "network", "mm:network:write", "write", "servers", z.object({ serverId, resource: z.enum(definition.resources), id: rowId, fields: z.record(z.string().max(100), z.union([z.string().max(1000), z.number().finite(), z.boolean()])) }).strict(), {
|
||||
version: definition.id === "interfaces.update" ? 4 : 2,
|
||||
version: definition.id === "interfaces.update" ? 5 : 2,
|
||||
prepare: async (input, context) => {
|
||||
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
|
||||
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
|
||||
@@ -301,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()
|
||||
@@ -322,6 +361,21 @@ for (const action of ["create", "update", "delete", "binding_add", "binding_remo
|
||||
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
|
||||
...(before?.bindings ?? []).map(binding => binding.serverId), ...(before?.servers ?? []).map(server => Number(server.serverId))])]
|
||||
if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Сервер не разрешён")
|
||||
if (action === "binding_remove" && !before?.bindings.some(binding => binding.id === input.bindingId)) {
|
||||
throw new McpError("NOT_FOUND", "Привязка не найдена", 404)
|
||||
}
|
||||
if (action === "binding_add") {
|
||||
try {
|
||||
await validateUserBindingCreate(String(input.id), userBindingCreateSchema.parse(input.binding), { signal: context.signal })
|
||||
} catch (error) {
|
||||
if (error instanceof UsersServiceError) {
|
||||
if (error.status === 409) throw new McpError("STATE_CONFLICT", error.message, 409)
|
||||
if (error.status === 400) throw new McpError("INVALID_INPUT", error.message, 400)
|
||||
if (error.status === 404) throw new McpError("NOT_FOUND", error.message, 404)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return { target: { scope: "global" }, objects: [{ kind: "user", id: String(input.id ?? "new") }, ...ids.map(id => ({ kind: "server", id: String(id), serverId: id }))], snapshots: [], before,
|
||||
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
|
||||
},
|
||||
@@ -350,16 +404,17 @@ function logFilters(input: Record<string, unknown>, context: ActionContext) {
|
||||
}
|
||||
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
||||
read: async (input, context) => {
|
||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
||||
const generation = await accessGeneration()
|
||||
let filters: ReturnType<typeof logFilters>
|
||||
let cursor: string | undefined, watermark: string | undefined
|
||||
if (input.cursor) {
|
||||
try {
|
||||
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
|
||||
if (input.from === undefined) filters.from = state.from
|
||||
filters = logFilters({ ...input, from: input.from ?? state.from }, context)
|
||||
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
|
||||
cursor = state.cursor; watermark = state.watermark
|
||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
|
||||
}
|
||||
} else filters = logFilters(input, context)
|
||||
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
|
||||
return { ...result, nextCursor: result.nextCursor ? await encryptSecret(JSON.stringify({ generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor: result.nextCursor, watermark: result.watermark, from: filters.from })) : null }
|
||||
},
|
||||
@@ -374,30 +429,41 @@ for (const context of [false, true]) add(context ? "logs.context" : "logs.get",
|
||||
return context ? readLogContext(String(input.id)) : entry
|
||||
},
|
||||
})
|
||||
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
||||
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, limit: logInput.shape.limit.default(200), cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
|
||||
read: async (input, context) => {
|
||||
const maintenance = context.logsFollowMaintenance
|
||||
const signal = context.signal
|
||||
const waitSignal = maintenance && signal ? AbortSignal.any([maintenance, signal]) : maintenance ?? signal
|
||||
const assertActive = () => { const failure = logsFollowCancellation(maintenance, signal); if (failure) throw failure }
|
||||
assertActive()
|
||||
const { server } = await routerClient(Number(input.serverId)); void server
|
||||
assertActive()
|
||||
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
||||
try {
|
||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
||||
const generation = await accessGeneration()
|
||||
let filters: ReturnType<typeof logFilters>
|
||||
let cursor = "0"
|
||||
if (input.cursor) {
|
||||
try {
|
||||
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
|
||||
filters.from = saved.from
|
||||
filters = logFilters({ ...input, from: input.from ?? saved.from }, context)
|
||||
if (saved.kind !== "follow" || saved.generation !== generation || saved.scope !== fingerprint({ filters, connection: context.connection.id }) || !/^\d{1,20}$/.test(saved.cursor)) throw new Error("cursor")
|
||||
cursor = saved.cursor
|
||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
|
||||
} else {
|
||||
filters = logFilters(input, context)
|
||||
cursor = (await pool.query("SELECT COALESCE(max(id),0)::text AS cursor FROM router_log_entries WHERE server_id=$1", [input.serverId])).rows[0].cursor
|
||||
}
|
||||
while (true) {
|
||||
assertActive()
|
||||
const current = await validatePrincipal(context.principal)
|
||||
assertActive()
|
||||
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
||||
const batch = await readLogStream(filters, cursor)
|
||||
const batch = await readLogStream(filters, cursor, Number(input.limit))
|
||||
assertActive()
|
||||
cursor = batch.cursor
|
||||
if (batch.entries.length || !batch.enabled || Date.now() >= until || context.signal?.aborted) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
if (batch.entries.length || !batch.enabled || Date.now() >= until) return { ...batch, cursor: await encryptSecret(JSON.stringify({ kind: "follow", generation, scope: fingerprint({ filters, connection: context.connection.id }), cursor, from: filters.from })) }
|
||||
await sleep(1000, undefined, { signal: waitSignal })
|
||||
}
|
||||
} finally { release() }
|
||||
},
|
||||
@@ -423,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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ try{
|
||||
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
||||
await updateMcpSettings({enabled:true},actor)
|
||||
app=await buildApp({startScheduler:false});await app.ready()
|
||||
await (await import("../../test/mcp-users-bindings.js")).verifyMcpUserBindings(actor)
|
||||
await (await import("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
|
||||
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
|
||||
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { z } from "zod"
|
||||
import { isIP } from "node:net"
|
||||
import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan"
|
||||
import { buildBridgeVlanConfiguration, bridgeVlanCurrentDesired, BridgeVlanConfigurationError, type BridgeVlanState } from "../../services/bridge-vlan-configuration.js"
|
||||
import { readRosSnapshot, routerClient, rosSnapshotSourceProof, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { verifyBridgeManagementChannel } from "./management.js"
|
||||
import { McpError } from "./security.js"
|
||||
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||
import type { add } from "./actions.js"
|
||||
|
||||
const interfaceResources = ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"]
|
||||
const resources = [...interfaceResources, "bridge_port", "bridge_vlan", "ipv4_address", "ipv6_address"]
|
||||
export async function readBridgeVlanState(serverId: number, context: Pick<ActionContext, "signal">): Promise<{ state: BridgeVlanState; snapshots: RosSnapshot[] }> {
|
||||
const snapshots: RosSnapshot[] = []
|
||||
const active = async () => { if (context.signal?.aborted) throw new McpError("READ_CANCELLED", "Чтение конфигурации VLAN отменено", 409) }
|
||||
// Bind every catalogue to the same configured device source. The executor
|
||||
// rechecks these complete configuration snapshots before each native write.
|
||||
for (const resource of resources) snapshots.push(await readRosSnapshot(serverId, resource, active, snapshots[0]?.sourceProof))
|
||||
const rows = (resource: string) => snapshots.find(snapshot => snapshot.resource === resource)!.rows
|
||||
const state: BridgeVlanState = {
|
||||
bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"),
|
||||
interfaces: snapshots.filter(snapshot => interfaceResources.includes(snapshot.resource)).flatMap(snapshot => snapshot.rows.map(row => ({
|
||||
resource: snapshot.resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled,
|
||||
}))),
|
||||
vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"),
|
||||
}
|
||||
return { state, snapshots }
|
||||
}
|
||||
|
||||
export async function prepareBridgeVlanConfiguration(raw: unknown, context: ActionContext): Promise<ChangeEvidence> {
|
||||
const input = bridgeVlanConfigurationRequestSchema.parse(raw)
|
||||
let management: Awaited<ReturnType<typeof verifyBridgeManagementChannel>> | undefined
|
||||
try { management = await verifyBridgeManagementChannel(input.serverId) }
|
||||
catch (error) {
|
||||
// Only lack of a current alternate channel permits primary-path admission.
|
||||
// Identity, transport and other verification failures stay fail-closed.
|
||||
if (!(error instanceof McpError) || error.code !== "APPROVAL_REQUIRED") throw error
|
||||
}
|
||||
const { state, snapshots } = await readBridgeVlanState(input.serverId, context)
|
||||
const primaryManagement = management === undefined
|
||||
if (!management) {
|
||||
const { server } = await routerClient(input.serverId)
|
||||
if (!isIP(server.host)) throw new McpError("MANAGEMENT_UNSAFE", "Основной адрес управления должен быть буквальным IP, подтверждённым в конфигурации устройства", 409)
|
||||
if (!snapshots[0]?.sourceProof || await rosSnapshotSourceProof(server) !== snapshots[0].sourceProof) throw new McpError("STATE_CONFLICT", "Источник основного управления изменился во время подготовки VLAN", 409)
|
||||
const expiresAt = Math.min(Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime(), Date.parse(context.principal.credentialExpiresAt))
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new McpError("APPROVAL_REQUIRED", "Нет действующего допуска для основного канала управления", 409)
|
||||
management = { channelId: "primary", serverId: input.serverId, host: server.host, expiresAt: new Date(expiresAt).toISOString() }
|
||||
}
|
||||
try {
|
||||
const plan = buildBridgeVlanConfiguration({ ...input, state, management, now: Date.now() })
|
||||
if (!plan.steps.length) throw new McpError("STATE_CONFLICT", "Конфигурация VLAN уже соответствует плану", 409)
|
||||
return {
|
||||
target: { scope: "servers", serverIds: [input.serverId] }, objects: plan.objects,
|
||||
snapshots, steps: plan.steps.map(step => ({ kind: "ros" as const, step })),
|
||||
requiresHuman: true, validUntil: management.expiresAt,
|
||||
...(primaryManagement ? {} : { managementChannels: [{ channelId: management.channelId, serverId: management.serverId, expiresAt: management.expiresAt }] }),
|
||||
warnings: ["Связанный план изменяет VLAN memberships и PVID в указанном порядке; включение filtering выполняется последним.",
|
||||
"Каждая подтверждённая запись проверяется до следующей. При неизвестном результате дальнейшие записи останавливаются.",
|
||||
primaryManagement ? "Используется основной адрес управления; независимость канала не подтверждена. Обязательно подтверждение человеком; каждый префикс плана сохраняет подтверждённый management-доступ."
|
||||
: "Проверяется конфигурация и доступность зарегистрированного management-канала. Передача пользовательского трафика требует отдельной сетевой проверки."],
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** A legacy single-row tool must pass the same effective-state checks, but may
|
||||
* not acquire authority to edit other resource kinds through this delegation. */
|
||||
export async function prepareGenericBridgeVlanChange(step: RosStep, context: ActionContext): Promise<ChangeEvidence | undefined> {
|
||||
const sensitive = step.resource === "bridge" ? ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"] : step.resource === "bridge_port"
|
||||
? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"] : ["bridge", "vlan-ids", "tagged", "untagged", "disabled"]
|
||||
if (!["bridge", "bridge_port", "bridge_vlan"].includes(step.resource) || step.resource === "bridge" && step.action !== "update"
|
||||
|| step.action === "update" && !Object.keys(step.fields).some(key => sensitive.includes(key))) return undefined
|
||||
const { state } = await readBridgeVlanState(step.serverId, context)
|
||||
const rows = step.resource === "bridge" ? state.bridges : step.resource === "bridge_port" ? state.ports : state.vlans
|
||||
const row = rows.find(item => item[".id"] === step.id)
|
||||
const bridgeName = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge
|
||||
const bridges = state.bridges.filter(item => item.name === bridgeName)
|
||||
if (bridges.length !== 1 || step.resource !== "bridge" && row && row.bridge !== bridgeName) throw new McpError("STATE_CONFLICT", "Смена bridge требует отдельных явных планов удаления и добавления порта", 409)
|
||||
const bridgeId = String(bridges[0][".id"])
|
||||
try {
|
||||
const desired = bridgeVlanCurrentDesired(state, bridgeId)
|
||||
const bool = (value: unknown): boolean => {
|
||||
if (value === "true" || value === true) return true
|
||||
if (value === "false" || value === false) return false
|
||||
throw new McpError("INVALID_INPUT", "Нужны явные параметры порта VLAN", 400)
|
||||
}
|
||||
const ref = (name: unknown) => {
|
||||
const matches = state.interfaces.filter(iface => iface.name === name)
|
||||
if (matches.length !== 1) throw new McpError("INVALID_INPUT", "Не подтверждён стабильный ID участника VLAN", 400)
|
||||
return { resource: matches[0].resource, id: matches[0].id }
|
||||
}
|
||||
if (step.resource === "bridge") {
|
||||
if (Object.keys(step.fields).some(key => !sensitive.includes(key))) throw new McpError("INVALID_INPUT", "Подготовьте свойства bridge и VLAN filtering отдельными планами", 400)
|
||||
if (step.fields.pvid !== undefined) desired.pvid = Number(step.fields.pvid)
|
||||
if (step.fields["vlan-filtering"] !== undefined) desired.filtering = bool(step.fields["vlan-filtering"])
|
||||
if (step.fields["frame-types"] !== undefined) desired.frameTypes = bridgeVlanConfigurationRequestSchema.shape.desired.shape.frameTypes.parse(step.fields["frame-types"])
|
||||
if (step.fields["ingress-filtering"] !== undefined) desired.ingressFiltering = bool(step.fields["ingress-filtering"])
|
||||
} else if (step.resource === "bridge_port") {
|
||||
const index = desired.ports.findIndex(port => port.id === step.id)
|
||||
if (step.action === "delete") {
|
||||
if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404)
|
||||
desired.ports.splice(index, 1); desired.removePortIds.push(step.id!)
|
||||
} else {
|
||||
const effective = { ...row, ...step.fields }
|
||||
const port = { ...(step.action === "update" ? { id: step.id } : {}), interface: ref(effective.interface), pvid: Number(effective.pvid),
|
||||
ingressFiltering: bool(effective["ingress-filtering"]), frameTypes: effective["frame-types"], disabled: bool(effective.disabled),
|
||||
...(effective.comment === undefined ? {} : { comment: effective.comment }) }
|
||||
const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.ports.element.parse(port)
|
||||
if (step.action === "create") desired.ports.push(parsed)
|
||||
else { if (index < 0) throw new McpError("NOT_FOUND", "Статический порт не найден", 404); desired.ports[index] = parsed }
|
||||
}
|
||||
} else {
|
||||
const index = desired.vlans.findIndex(vlan => vlan.id === step.id)
|
||||
if (step.action === "delete") {
|
||||
if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404)
|
||||
desired.vlans.splice(index, 1); desired.removeVlanIds.push(step.id!)
|
||||
} else {
|
||||
const effective = { ...row, ...step.fields }
|
||||
const members = (value: unknown) => value === undefined || value === "" ? [] : String(value).split(",").map(ref)
|
||||
const vlan = { ...(step.action === "update" ? { id: step.id } : {}), vlanIds: effective["vlan-ids"], tagged: members(effective.tagged), untagged: members(effective.untagged),
|
||||
disabled: effective.disabled === undefined && step.action === "create" ? false : bool(effective.disabled), ...(effective.comment === undefined ? {} : { comment: effective.comment }) }
|
||||
const parsed = bridgeVlanConfigurationRequestSchema.shape.desired.shape.vlans.element.parse(vlan)
|
||||
if (step.action === "create") desired.vlans.push(parsed)
|
||||
else { if (index < 0) throw new McpError("NOT_FOUND", "Статическая VLAN membership не найдена", 404); desired.vlans[index] = parsed }
|
||||
}
|
||||
}
|
||||
const evidence = await prepareBridgeVlanConfiguration({ serverId: step.serverId, bridgeId, desired }, context)
|
||||
if (evidence.steps.length !== 1 || evidence.steps[0].kind !== "ros" || evidence.steps[0].step.resource !== step.resource || evidence.steps[0].step.action !== step.action || evidence.steps[0].step.id !== step.id) {
|
||||
throw new McpError("APPROVAL_REQUIRED", "Для связанных изменений используйте явный план mm_bridge_vlan_configuration_plan с соответствующим разрешением", 409)
|
||||
}
|
||||
const native = evidence.steps[0].step
|
||||
if (step.action === "update" && Object.entries(native.fields).some(([key, value]) => !Object.hasOwn(step.fields, key)
|
||||
|| (step.resource === "bridge_vlan" ? canonicalBridgeVlanValue(key, value) !== canonicalBridgeVlanValue(key, step.fields[key]) : value !== step.fields[key]))) {
|
||||
throw new McpError("STATE_CONFLICT", "Состояние VLAN изменилось во время подготовки; незапрошенные поля не будут перезаписаны", 409)
|
||||
}
|
||||
return evidence
|
||||
} catch (error) {
|
||||
if (error instanceof BridgeVlanConfigurationError) throw new McpError(error.code, error.message, 409)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBridgeVlanActions(register: typeof add) {
|
||||
register("network.bridge-vlan.read", "mm_bridge_vlan_configuration_get", "Полная конфигурация bridge VLAN и стабильные ID участников", "network", "mm:network:read", "read", "servers", z.object({ serverId: z.number().int().positive() }).strict(), {
|
||||
read: async (input, context) => ({ state: (await readBridgeVlanState(Number(input.serverId), context)).state }),
|
||||
})
|
||||
register("network.bridge-vlan.configure", "mm_bridge_vlan_configuration_plan", "Связанный план VLAN membership, PVID и bridge filtering", "network", "mm:network:write", "write", "servers", bridgeVlanConfigurationRequestSchema, {
|
||||
prepare: prepareBridgeVlanConfiguration,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict"
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "bridge-vlan-owned-acceptance-secret"
|
||||
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||
const { pool, closePool } = await import("../../db/index.js")
|
||||
const { applySqlMigrations } = await import("../../db/migrate.js")
|
||||
const { updateMcpSettings } = await import("./store.js")
|
||||
const { buildApp } = await import("../../index.js")
|
||||
const actor = { id: "bridge-vlan-owner", email: "bridge@example.invalid", name: "Bridge VLAN owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin"], expiresAt: Date.now() + 3600000 }
|
||||
let app: Awaited<ReturnType<typeof buildApp>> | undefined
|
||||
try {
|
||||
await applySqlMigrations(pool)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0, "Disposable empty database required")
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
app = await buildApp({ startScheduler: false }); await app.ready()
|
||||
await (await import("../../test/mcp-bridge-vlan-configuration.js")).verifyMcpBridgeVlanConfiguration(actor, app)
|
||||
} finally {
|
||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||
await (await import("../../services/traffic-flow-ingest.js")).stopTrafficFlowListenerForDatabaseOperation()
|
||||
await app?.close(); await closePool()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { configurationRows, resourceById, rosSnapshotVersion, type ConfigurationRow, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { createRosPrefix } from "./ros-prefix.js"
|
||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||
import { fingerprint, McpError } from "./security.js"
|
||||
|
||||
const snapshot = (resource: string, raw: ConfigurationRow[]): RosSnapshot => {
|
||||
const rows = configurationRows(resourceById(resource), raw)
|
||||
return { serverId: 1, resource, rows, fingerprint: fingerprint(rows), sourceProof: "a".repeat(64), projectionVersion: rosSnapshotVersion(resource) }
|
||||
}
|
||||
const membership = { ".id": "*1", bridge: "br", "vlan-ids": "20-22", tagged: "ether2,ether3", dynamic: "false", disabled: "false" }
|
||||
|
||||
test("VLAN readback accepts equivalent VID/member sets and omitted empty fields, retaining exact native identity", () => {
|
||||
const before = snapshot("bridge_vlan", [membership])
|
||||
const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "22,20,21", tagged: "ether3,ether2" }])
|
||||
const step: RosStep = { serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "20-22", tagged: "ether3,ether2", untagged: "", comment: "" } }
|
||||
assert.equal(verifyRosStepSnapshots(step, {}, before, after), true)
|
||||
assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20-23" }])), false)
|
||||
assert.equal(verifyRosStepSnapshots(step, {}, before, snapshot("bridge_vlan", [{ ...membership, ".id": "*2" }])), false)
|
||||
assert.throws(() => snapshot("bridge_vlan", [{ ...membership, "vlan-ids": "20,20-22" }]), /Недостоверная/)
|
||||
})
|
||||
|
||||
test("PVID-generated rows may change while the sealed static VLAN configuration remains exact", async () => {
|
||||
const port = { ".id": "*2", bridge: "br", interface: "ether2", pvid: "10", disabled: "false" }
|
||||
const dynamic = { ".id": "*D", bridge: "br", "vlan-ids": "10", untagged: "ether2", dynamic: "true" }
|
||||
const raw = new Map<string, ConfigurationRow[]>([["bridge_port", [port]], ["bridge_vlan", [membership, dynamic]]])
|
||||
const initial = [...raw].map(([resource, rows]) => snapshot(resource, rows))
|
||||
const read = async (_server: number, resource: string, active: () => Promise<unknown> = async () => {}) => { await active(); return snapshot(resource, raw.get(resource)!) }
|
||||
const prefix = createRosPrefix(initial, read)
|
||||
await prefix.check()
|
||||
raw.set("bridge_port", [{ ...port, pvid: "20" }])
|
||||
raw.set("bridge_vlan", [membership, { ...dynamic, ".id": "*E", "vlan-ids": "20" }])
|
||||
const step: RosStep = { serverId: 1, resource: "bridge_port", action: "update", id: "*2", fields: { pvid: "20" } }
|
||||
await prefix.advance(step, {}, await read(1, "bridge_port"))
|
||||
await prefix.check()
|
||||
raw.set("bridge_vlan", [{ ...membership, tagged: "ether2" }, dynamic])
|
||||
await assert.rejects(prefix.check(), error => error instanceof McpError && error.code === "STATE_CONFLICT")
|
||||
})
|
||||
|
||||
test("full VLAN ranges and long sparse VID sets stay representable in plans and inverse snapshots", () => {
|
||||
const resource = resourceById("bridge_vlan")
|
||||
const expanded = Array.from({ length: 4094 }, (_, index) => String(index + 1)).join(",")
|
||||
const sparse = Array.from({ length: 2047 }, (_, index) => String(index * 2 + 1)).join(",")
|
||||
assert.ok(sparse.length > 100)
|
||||
for (const value of ["1-4094", sparse]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, true)
|
||||
for (const value of ["0", "4095", "10-9", "10,10-12"]) assert.equal(resource.fields.safeParse({ "vlan-ids": value }).success, false)
|
||||
const before = snapshot("bridge_vlan", [membership])
|
||||
const after = snapshot("bridge_vlan", [{ ...membership, "vlan-ids": expanded }])
|
||||
assert.equal(after.rows[0]["vlan-ids"], "1-4094", "Native expansion must not overflow the public inverse DTO")
|
||||
assert.equal(verifyRosStepSnapshots({ serverId: 1, resource: "bridge_vlan", action: "update", id: "*1", fields: { "vlan-ids": "1-4094" } }, {}, before, after), true)
|
||||
})
|
||||
|
||||
test("unknown dynamic flags are retained; only explicit RouterOS-generated VLAN rows are excluded", () => {
|
||||
assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "unknown" }]).rows.length, 1)
|
||||
for (const dynamic of [true, "true", "yes"]) assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic }]).rows.length, 0)
|
||||
assert.equal(snapshot("bridge_vlan", [{ ...membership, dynamic: "false" }]).rows.length, 1)
|
||||
})
|
||||
|
||||
test("old bridge CPU, port and VLAN projection contracts cannot execute after semantic projection changes", () => {
|
||||
for (const resource of ["bridge", "bridge_port", "bridge_vlan"]) {
|
||||
const current = snapshot(resource, resource === "bridge_vlan" ? [membership] : [{ ".id": "*2", bridge: "br", interface: "ether2", pvid: "10" }])
|
||||
assert.equal(current.projectionVersion, 4)
|
||||
assert.throws(() => createRosPrefix([{ ...current, projectionVersion: 3 }]), error => error instanceof McpError && error.code === "PLAN_EXPIRED")
|
||||
}
|
||||
})
|
||||
test("CPU frame admission and MVRP configuration are sealed, with no MVRP write permission", () => {
|
||||
const bridge = { ".id": "*1", name: "br", pvid: "10", "vlan-filtering": "true", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false", running: "true" }
|
||||
const original = snapshot("bridge", [bridge])
|
||||
for (const field of ["frame-types", "ingress-filtering", "mvrp"]) {
|
||||
const changed = snapshot("bridge", [{ ...bridge, [field]: field === "frame-types" ? "admit-only-vlan-tagged" : "false" === bridge[field as keyof typeof bridge] ? "true" : "false" }])
|
||||
assert.notEqual(changed.fingerprint, original.fingerprint)
|
||||
}
|
||||
assert.equal(snapshot("bridge", [{ ...bridge, running: "false" }]).fingerprint, original.fingerprint)
|
||||
assert.equal(resourceById("bridge").fields.safeParse({ mvrp: true }).success, false)
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { bridgeVlanStateSchema, type BridgeVlanDesired, type BridgeVlanState } from "@mmapp/contracts/bridge-vlan"
|
||||
import { bridgeVlanCurrentDesired } from "../../services/bridge-vlan-configuration.js"
|
||||
import type { ActionContext, ChangeEvidence } from "./action-types.js"
|
||||
import { prepareBridgeVlanConfiguration, readBridgeVlanState } from "./bridge-vlan-actions.js"
|
||||
import { acknowledgedCreateId, assertNoCallerCreateId, recoveryRosStepId } from "./native-create-identity.js"
|
||||
import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||
|
||||
type Checkpoint = { step_index: number; before_sealed: string; after_sealed: string | null }
|
||||
type Intended = { step_index: number; state: string; result: unknown }
|
||||
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
||||
function conflict(message: string): never { throw new McpError("STATE_CONFLICT", message, 409) }
|
||||
|
||||
export function needsBridgeVlanRecovery(sourceAction: string, evidence: ChangeEvidence): boolean {
|
||||
if (sourceAction === "network.bridge-vlan.configure") return true
|
||||
return evidence.steps.some(change => {
|
||||
if (change.kind !== "ros") return false
|
||||
const step = change.step
|
||||
if (step.resource === "bridge") return step.action === "update" && Object.keys(step.fields).some(field => ["pvid", "vlan-filtering", "frame-types", "ingress-filtering"].includes(field))
|
||||
const sensitive = step.resource === "bridge_port" ? ["bridge", "interface", "pvid", "ingress-filtering", "frame-types", "disabled"]
|
||||
: step.resource === "bridge_vlan" ? ["bridge", "vlan-ids", "tagged", "untagged", "disabled"] : []
|
||||
return sensitive.length > 0 && (step.action !== "update" || Object.keys(step.fields).some(field => sensitive.includes(field)))
|
||||
})
|
||||
}
|
||||
|
||||
function validSnapshot(snapshot: RosSnapshot): boolean {
|
||||
return snapshot.projectionVersion === rosSnapshotVersion(snapshot.resource) && !!snapshot.sourceProof && Array.isArray(snapshot.rows) && snapshot.fingerprint === fingerprint(snapshot.rows)
|
||||
}
|
||||
function sameSnapshot(left: RosSnapshot, right: RosSnapshot): boolean {
|
||||
return validSnapshot(left) && validSnapshot(right) && key(left) === key(right) && left.fingerprint === right.fingerprint && left.sourceProof === right.sourceProof
|
||||
}
|
||||
function stateFromSnapshots(serverId: number, snapshots: Map<string, RosSnapshot>): BridgeVlanState {
|
||||
const rows = (resource: string) => {
|
||||
const snapshot = snapshots.get(key({ serverId, resource }))
|
||||
if (!snapshot || !validSnapshot(snapshot)) conflict("Нет полного исходного снимка bridge VLAN с актуальной проекцией")
|
||||
return snapshot.rows
|
||||
}
|
||||
return bridgeVlanStateSchema.parse({ bridges: rows("bridge"), ports: rows("bridge_port"), vlans: rows("bridge_vlan"),
|
||||
vlanInterfaces: rows("vlan"), ipv4Addresses: rows("ipv4_address"), ipv6Addresses: rows("ipv6_address"),
|
||||
interfaces: ["ethernet", "bridge", "vlan", "gre", "wireguard", "vxlan"].flatMap(resource => rows(resource).map(row => ({ resource, id: String(row[".id"]), name: String(row.name), disabled: row.disabled }))),
|
||||
})
|
||||
}
|
||||
|
||||
/** Fresh typed inverse of a completely verified transition. Deleted port/VLAN
|
||||
* rows are recreated without their old IDs; new rows are removed only by ACK. */
|
||||
export async function prepareBridgeVlanRecovery(input: {
|
||||
operationId: string; evidence: ChangeEvidence; checkpoints: Checkpoint[]; intended: Intended[];
|
||||
}, context: ActionContext): Promise<ChangeEvidence> {
|
||||
const { operationId, evidence, checkpoints, intended } = input
|
||||
if (!evidence.steps.length || evidence.steps.some(change => change.kind !== "ros" || !["bridge", "bridge_port", "bridge_vlan"].includes(change.step.resource))) {
|
||||
throw new McpError("UNSUPPORTED_CAPABILITY", "Обратный VLAN план не поддерживает смешанные ресурсы", 409)
|
||||
}
|
||||
const native = evidence.steps.map(change => (change as { kind: "ros"; step: RosStep }).step)
|
||||
if (native.some(step => step.resource === "bridge" && step.action !== "update")) {
|
||||
throw new McpError("UNSUPPORTED_CAPABILITY", "Создание или удаление самого bridge не поддерживает типизированный обратный VLAN план", 409)
|
||||
}
|
||||
const serverIds = [...new Set(native.map(step => step.serverId))]
|
||||
if (serverIds.length !== 1 || evidence.target.scope !== "servers" || evidence.target.serverIds.length !== 1 || evidence.target.serverIds[0] !== serverIds[0]) conflict("Нет однозначного сервера исходного VLAN плана")
|
||||
const serverId = serverIds[0]
|
||||
if (!context.connection.policy.scope.serverIds.includes(serverId)) throw new McpError("SERVER_DENIED", "VLAN сервер вне текущего допуска")
|
||||
if (intended.length !== native.length || checkpoints.length !== native.length || new Set(intended.map(item => item.step_index)).size !== native.length || new Set(checkpoints.map(item => item.step_index)).size !== native.length) conflict("Нет полного набора подтверждённых шагов VLAN")
|
||||
const baseline = new Map<string, RosSnapshot>()
|
||||
for (const snapshot of evidence.snapshots) {
|
||||
if (snapshot.serverId !== serverId || !validSnapshot(snapshot) || baseline.has(key(snapshot))) conflict("Исходные VLAN снимки или их версии не подтверждены")
|
||||
baseline.set(key(snapshot), snapshot)
|
||||
}
|
||||
const baselineState = stateFromSnapshots(serverId, baseline), expected = new Map(baseline)
|
||||
const created: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = []
|
||||
const deleted: Array<{ resource: "bridge_port" | "bridge_vlan"; id: string }> = []
|
||||
for (let index = 0; index < native.length; index++) {
|
||||
const step = native[index], checkpoint = checkpoints.find(item => item.step_index === index), receipt = intended.find(item => item.step_index === index)
|
||||
assertNoCallerCreateId(step)
|
||||
if (!checkpoint?.before_sealed || !checkpoint.after_sealed || receipt?.state !== "verified") conflict("Нет полного проверенного checkpoint VLAN; требуется ручная сверка")
|
||||
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot
|
||||
const after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
|
||||
const previous = expected.get(key({ serverId, resource: step.resource }))
|
||||
if (!previous || !sameSnapshot(previous, before) || !validSnapshot(after) || before.sourceProof !== after.sourceProof || !verifyRosStepSnapshots(step, receipt.result, before, after)) conflict("Исходный переход VLAN или источник не подтверждён")
|
||||
if (step.action === "create") {
|
||||
const ack = acknowledgedCreateId(receipt.result), actual = recoveryRosStepId(step, before.rows, after.rows)
|
||||
if (!ack || actual !== ack || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Нет точного ACK и checkpoint ID созданного VLAN объекта")
|
||||
created.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: ack })
|
||||
} else if (step.action === "delete") {
|
||||
if (!step.id || !["bridge_port", "bridge_vlan"].includes(step.resource)) conflict("Не подтверждён ID удалённого VLAN объекта")
|
||||
deleted.push({ resource: step.resource as "bridge_port" | "bridge_vlan", id: step.id })
|
||||
}
|
||||
expected.set(key(after), after)
|
||||
}
|
||||
const bridgeNames = new Set(native.map(step => {
|
||||
const rows = step.resource === "bridge" ? baselineState.bridges : step.resource === "bridge_port" ? baselineState.ports : baselineState.vlans
|
||||
const row = rows.find(item => item[".id"] === step.id)
|
||||
const name = step.resource === "bridge" ? row?.name : step.fields.bridge ?? row?.bridge
|
||||
if (typeof name !== "string" || !name) conflict("Не подтверждён исходный bridge VLAN")
|
||||
return name
|
||||
}))
|
||||
const bridges = baselineState.bridges.filter(row => bridgeNames.has(String(row.name)))
|
||||
if (bridgeNames.size !== 1 || bridges.length !== 1) conflict("Обратный VLAN план требует один однозначный bridge")
|
||||
const bridgeId = String(bridges[0][".id"]), desired: BridgeVlanDesired = bridgeVlanCurrentDesired(baselineState, bridgeId)
|
||||
for (const item of deleted) {
|
||||
const terminal = expected.get(key({ serverId, resource: item.resource }))
|
||||
const original = item.resource === "bridge_port" ? desired.ports.find(row => row.id === item.id) : desired.vlans.find(row => row.id === item.id)
|
||||
if (!original || !terminal || terminal.rows.some(row => row[".id"] === item.id)) conflict("Нет подтверждённого удаления исходной VLAN identity")
|
||||
delete original.id
|
||||
}
|
||||
for (const item of created) {
|
||||
const originalRows = baseline.get(key({ serverId, resource: item.resource }))!.rows
|
||||
const terminalRows = expected.get(key({ serverId, resource: item.resource }))!.rows
|
||||
if (originalRows.some(row => row[".id"] === item.id) || !terminalRows.some(row => row[".id"] === item.id)) conflict("ACK ID VLAN не является подтверждённым новым объектом")
|
||||
if (item.resource === "bridge_port") desired.removePortIds.push(item.id)
|
||||
else desired.removeVlanIds.push(item.id)
|
||||
}
|
||||
const compare = async (snapshots: RosSnapshot[]): Promise<RosSnapshot[]> => {
|
||||
const captured = new Map(snapshots.map(snapshot => [key(snapshot), snapshot]))
|
||||
for (const snapshot of snapshots) {
|
||||
const terminal = expected.get(key(snapshot))
|
||||
if (!terminal || !sameSnapshot(terminal, snapshot)) conflict("После операции изменились VLAN конфигурация, зависимости или источник; обратный план запрещён")
|
||||
}
|
||||
for (const [resourceKey, terminal] of expected) {
|
||||
const current = captured.get(resourceKey) ?? await readRosSnapshot(serverId, terminal.resource, undefined, terminal.sourceProof)
|
||||
if (!sameSnapshot(terminal, current)) conflict("После операции обнаружен drift зависимости VLAN; обратный план запрещён")
|
||||
captured.set(resourceKey, current)
|
||||
}
|
||||
return [...captured.values()]
|
||||
}
|
||||
await compare((await readBridgeVlanState(serverId, context)).snapshots)
|
||||
// Reconstruct through the same model, with a freshly verified management
|
||||
// channel. Old receipts never confer authority to run the inverse.
|
||||
const inverse = await prepareBridgeVlanConfiguration({ serverId, bridgeId, desired }, context)
|
||||
const snapshots = await compare(inverse.snapshots)
|
||||
const objects = new Map([...evidence.objects, ...inverse.objects].map(object => [`${object.serverId}/${object.kind}/${object.id}`, object]))
|
||||
return { ...inverse, snapshots, objects: [...objects.values()], requiresHuman: true,
|
||||
warnings: [`Типизированный обратный VLAN план операции ${operationId}; все исходные шаги и ACK проверены.`, ...inverse.warnings,
|
||||
"Удалённые port/VLAN rows создаются с новыми ID RouterOS. Unknown операции не повторяются; восстановление самого bridge не поддерживается."],
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,9 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests } from "./diagnostic-cancellation-source.js"
|
||||
import { mkdtemp, readdir, rm, rmdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createDiagnosticCancellationSourceForTests, createReadServerProbeExecutionSourceForTests, readServerProbeExecutionSource } from "./diagnostic-cancellation-source.js"
|
||||
import { fingerprint, redact } from "./security.js"
|
||||
|
||||
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
||||
@@ -25,6 +28,7 @@ function fixture() {
|
||||
return { rows }
|
||||
} }
|
||||
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
|
||||
readWithExistingKey: () => readServerProbeExecutionSource(snapshot, operationId, owner),
|
||||
refresh, binding, input, evidence, row, sealed, setRows: (value: typeof rows) => { rows=value }, calls: () => calls, operationId, credentialId }
|
||||
}
|
||||
{
|
||||
@@ -100,4 +104,22 @@ for (const mutate of mutations) {
|
||||
assert.equal(source.originalContext.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
|
||||
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
|
||||
}
|
||||
console.log(`diagnostic-cancellation-source: locked/read-only source, expired original contexts and ${mutations.length} shared tamper checks passed`)
|
||||
{
|
||||
const state = await mkdtemp(join(tmpdir(), "mm-legacy-source-test-"))
|
||||
const previousState = process.env.MCP_STATE_DIR
|
||||
process.env.MCP_STATE_DIR = state
|
||||
try {
|
||||
const f = fixture()
|
||||
await f.refresh()
|
||||
f.row.evidence = { sealed: Buffer.alloc(32).toString("base64") }
|
||||
await assert.rejects(f.readWithExistingKey, { code: "STATE_CONFLICT" })
|
||||
assert.equal(f.readSql.length, 1)
|
||||
assert.deepEqual(await readdir(state), [], "Historical source reads must leave missing encryption state untouched")
|
||||
} finally {
|
||||
if (previousState === undefined) delete process.env.MCP_STATE_DIR
|
||||
else process.env.MCP_STATE_DIR = previousState
|
||||
await rm(join(state, "encryption-key"), { force: true })
|
||||
await rmdir(state)
|
||||
}
|
||||
}
|
||||
console.log(`diagnostic-cancellation-source: locked/read-only source, missing key, expired original contexts and ${mutations.length} shared tamper checks passed`)
|
||||
@@ -3,7 +3,7 @@ import type { ProbeStorageScope, ProbeStorageSql } from "../../services/server-p
|
||||
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
|
||||
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
||||
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
|
||||
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
|
||||
import { decryptSecret, decryptSecretExisting, fingerprint, privateFingerprinterExisting, privateSecretFingerprint, redact, McpError } from "./security.js"
|
||||
|
||||
const MAX_BYTES = 2097152
|
||||
const uuid = z.string().uuid()
|
||||
@@ -113,7 +113,10 @@ export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
|
||||
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
|
||||
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
||||
/** Historical authentication only; confers no execution/cancellation grant. */
|
||||
export const readServerProbeExecutionSource = readSourceLoader(productionCrypto)
|
||||
export const readServerProbeExecutionSource = readSourceLoader({
|
||||
open: decryptSecretExisting,
|
||||
proof: async value => (await privateFingerprinterExisting("server-probe-mcp-binding-v1"))(fingerprint(value)),
|
||||
})
|
||||
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
|
||||
/** Exact previously locked source projection for private history conversion. */
|
||||
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
|
||||
|
||||
@@ -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")
|
||||
@@ -2889,6 +2918,55 @@ try {
|
||||
assert.ok(!result.isError)
|
||||
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
||||
} finally { await client.close() }
|
||||
{
|
||||
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||
...input, name: "Logs follow maintenance", permissions: ["mm:servers:read"],
|
||||
scope: { serverIds: [1], operationIds: ["logs.follow"], allowGlobal: false },
|
||||
}), actor)
|
||||
const follower = new Client({ name: "logs-follow-maintenance", version: "1.0.0" })
|
||||
const { sharedDatabaseGate } = await import("../../services/shared-database-gate.js")
|
||||
const original = pool.query
|
||||
let arrived!: () => void, release!: () => void
|
||||
const reading = new Promise<void>(resolve => { arrived = resolve })
|
||||
const held = new Promise<void>(resolve => { release = resolve })
|
||||
try {
|
||||
await follower.connect(new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { Authorization: `Bearer ${access.token}` } } }))
|
||||
pool.query = (async (...args: Parameters<typeof original>) => {
|
||||
const text = typeof args[0] === "string" ? args[0] : args[0]?.text
|
||||
const result = await Reflect.apply(original, pool, args)
|
||||
if (typeof text === "string" && text.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
|
||||
arrived()
|
||||
await held
|
||||
}
|
||||
return result
|
||||
}) as typeof original
|
||||
const following = follower.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } })
|
||||
try {
|
||||
const observation = new AbortController()
|
||||
try {
|
||||
await Promise.race([reading, following.then(() => { throw new Error("Follow finished before its stream query") }),
|
||||
delay(5000, undefined, { signal: observation.signal }).then(() => { throw new Error("Follow stream query was not observed") })])
|
||||
} finally { observation.abort() }
|
||||
let exclusiveEntered = false
|
||||
const maintenance = sharedDatabaseGate.exclusive(async () => { exclusiveEntered = true }, 5000)
|
||||
try {
|
||||
await delay(25)
|
||||
assert.equal(exclusiveEntered, false, "maintenance must wait for the pending database read")
|
||||
} finally {
|
||||
release()
|
||||
await maintenance
|
||||
}
|
||||
const result = await following
|
||||
assert.equal(result.isError, true)
|
||||
assert.match(JSON.stringify(result), /RESET_REQUIRED/)
|
||||
assert.doesNotMatch(JSON.stringify(result), /"cursor"/)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
|
||||
} finally { release(); await following.catch(() => undefined) }
|
||||
} finally { pool.query = original; await follower.close() }
|
||||
}
|
||||
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" }))
|
||||
@@ -2907,17 +2985,41 @@ try {
|
||||
await (await import("../../test/mcp-host-files.js")).verifyMcpHostFiles(actor,app)
|
||||
const readinessAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness handshake", scope: { ...input.scope, operationIds: ["servers.list"] } }), actor)
|
||||
const readinessHeaders = { authorization: "Bearer " + jwt }
|
||||
const simulatorHeaders = { authorization: "Bearer " + await new SignJWT({ sub: "policy-preview-admin", apps: ["mm"], permissions: ["mm:mcp:admin"] })
|
||||
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret)) }
|
||||
const policyPreview = (payload: Record<string, unknown>) => app!.inject({ method: "POST", url: "/api/mcp/policy/preview", headers: simulatorHeaders, payload })
|
||||
const simulatedServerRead = { connectionId: readinessAccess.connection.id, operationId: "servers.list", target: { scope: "servers", serverIds: [1] } }
|
||||
const simulation = await policyPreview(simulatedServerRead)
|
||||
assert.equal(simulation.statusCode, 200, simulation.body)
|
||||
assert.equal(simulation.json().result.decision, "allowed", "an administrator simulates the stored owner's rights, not the administrator's rights")
|
||||
assert.equal(simulation.json().executionAvailable, false)
|
||||
assert.equal((await policyPreview({ ...simulatedServerRead, ownerPermissions: ["mm:settings:admin"] })).statusCode, 400)
|
||||
const ready = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||
assert.equal(ready.statusCode, 200, ready.body)
|
||||
assert.equal(ready.json().ok, true, ready.body); assert.equal(ready.json().checks.protocolHandshake, true)
|
||||
assert.equal(ready.json().protocolProbe.status, "passed"); assert.equal(ready.json().protocolProbe.clientCredentialsVerified, false)
|
||||
assert.equal(ready.json().protocolProbe.capabilitiesRead, "skipped")
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readinessAccess.connection.id])).rows[0].n, 0)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readinessAccess.connection.id])).rows[0].n, 0)
|
||||
const duplicateReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||
assert.equal(duplicateReady.statusCode, 429)
|
||||
await setConnectionStatus(readinessAccess.connection.id, "paused", actor)
|
||||
const pausedSimulation = await policyPreview(simulatedServerRead)
|
||||
assert.equal(pausedSimulation.statusCode, 200, pausedSimulation.body)
|
||||
assert.equal(pausedSimulation.json().result.decision, "denied")
|
||||
assert.ok(pausedSimulation.json().result.reasons.some((reason: { code: string }) => reason.code === "CONNECTION_INACTIVE"))
|
||||
const pausedReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||
assert.equal(pausedReady.statusCode, 200); assert.equal(pausedReady.json().protocolProbe.status, "skipped")
|
||||
const readableAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness capability read", scope: { ...input.scope, operationIds: ["servers.list", "capabilities.read"] } }), actor)
|
||||
const readable = await app.inject({ method: "POST", url: `/api/mcp/connections/${readableAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||
assert.equal(readable.statusCode, 200, readable.body)
|
||||
assert.equal(readable.json().ok, true, readable.body)
|
||||
assert.equal(readable.json().protocolProbe.capabilitiesRead, "passed")
|
||||
assert.equal(readable.json().protocolProbe.credentialType, "temporary-capabilities-read")
|
||||
const readinessReads = (await pool.query("SELECT tool FROM mcp_requests WHERE connection_id=$1 AND tool NOT LIKE 'protocol:%'", [readableAccess.connection.id])).rows
|
||||
assert.deepEqual(readinessReads.map(row => row.tool), ["mm_capabilities_get"])
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe'", [readableAccess.connection.id])).rows[0].n, 0)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [readableAccess.connection.id])).rows[0].n, 0)
|
||||
const emptyGrantAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Readiness has no operation authority" }), actor)
|
||||
const { withMcpProbeCredential } = await import("./readiness.js")
|
||||
let endedProbeToken = ""
|
||||
@@ -2933,7 +3035,7 @@ try {
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows[0].n, 0)
|
||||
const readinessAudit = (await pool.query("SELECT payload FROM mcp_audit WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows
|
||||
assert.ok(!JSON.stringify(readinessAudit).includes(endedProbeToken))
|
||||
console.log("✓ Actual readiness initialize/tools-list over public HTTP: temporary empty grant, no tool authority, cleanup on success/error, cooldown and paused precheck skip")
|
||||
console.log("✓ Actual readiness initialize/tools-list and optional capabilities read over public HTTP: narrow grant, cleanup on success/error, cooldown and paused precheck skip")
|
||||
const deviceConnection = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Web interfaces", scope: { ...input.scope, operationIds: ["ros.bridge.list", "ros.bridge.create"] } }), actor)
|
||||
const deviceHeaders = { authorization: "Bearer " + jwt }
|
||||
const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders })
|
||||
@@ -2964,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"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { McpError } from "./security.js"
|
||||
|
||||
export function logsFollowCancellation(maintenance?: AbortSignal, caller?: AbortSignal): McpError | undefined {
|
||||
if (maintenance?.aborted) return maintenance.reason
|
||||
if (caller?.aborted) return new McpError("READ_CANCELLED", "Ожидание новых событий отменено", 409)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import assert from "node:assert/strict"
|
||||
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, 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({
|
||||
name: "Logs follow SDK cancellation", mode: "approval", permissions: ["mm:servers:read"],
|
||||
expiresAt: new Date(Math.min(actor.expiresAt ?? Infinity, Date.now() + 600_000)).toISOString(),
|
||||
scope: { serverIds: [1], operationIds: ["logs.follow", "logs.query"], allowGlobal: false },
|
||||
}), actor)
|
||||
const client = new Client({ name: "logs-follow-sdk-cancellation", version: "1.0.0" }, {
|
||||
versionNegotiation: { mode: { pin: "2026-07-28" } },
|
||||
})
|
||||
const caller = new AbortController(), bound = new AbortController()
|
||||
const deadline = delay(5000, undefined, { signal: bound.signal }).then(() => { throw new Error("SDK cancellation fixture deadline") })
|
||||
void deadline.catch(() => undefined)
|
||||
let entered!: () => void, release!: () => void, closed!: () => void
|
||||
const enteredRead = new Promise<void>(resolve => { entered = resolve })
|
||||
const holdRead = new Promise<void>(resolve => { release = resolve })
|
||||
const responseClosed = new Promise<void>(resolve => { closed = resolve })
|
||||
let readCount = 0
|
||||
const originalQuery = pool.query
|
||||
const observeClose = (request: IncomingMessage, response: ServerResponse) => {
|
||||
if (request.method === "POST" && request.url === "/mcp") {
|
||||
response.once("close", () => { if (!response.writableFinished) closed() })
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${access.token}` } },
|
||||
}))
|
||||
app.server.on("request", observeClose)
|
||||
pool.query = (async (...args: unknown[]) => {
|
||||
const query = args[0]
|
||||
const sql = typeof query === "object" && query !== null && "text" in query ? query.text : query
|
||||
const result = await Reflect.apply(originalQuery, pool, args)
|
||||
if (typeof sql === "string" && sql.startsWith("SELECT GREATEST(COALESCE(max(id),0)")) {
|
||||
readCount++
|
||||
entered()
|
||||
await holdRead
|
||||
}
|
||||
return result
|
||||
}) as typeof originalQuery
|
||||
const follow = client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 30 } }, { signal: caller.signal })
|
||||
const refused = assert.rejects(follow)
|
||||
try {
|
||||
await Promise.race([enteredRead, deadline])
|
||||
caller.abort()
|
||||
await Promise.race([Promise.all([refused, responseClosed]), deadline])
|
||||
const live = await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])
|
||||
assert.equal(live.rows[0].count, 1, "caller cancellation must wait for the pending read before releasing the lease")
|
||||
release()
|
||||
for (;;) {
|
||||
const { rows: [audit] } = await pool.query(
|
||||
"SELECT status,error_code,result FROM mcp_requests WHERE connection_id=$1 AND tool='mm_logs_follow'", [access.connection.id],
|
||||
)
|
||||
const protocol = await pool.query("SELECT count(*)::int AS count FROM mcp_requests WHERE connection_id=$1 AND tool='protocol:tools/call' AND finished_at IS NOT NULL", [access.connection.id])
|
||||
if (audit && audit.status !== "running" && protocol.rows[0].count === 1) {
|
||||
assert.equal(audit.status, "error")
|
||||
assert.equal(audit.error_code, "READ_CANCELLED")
|
||||
assert.doesNotMatch(JSON.stringify(audit.result), /"cursor"/)
|
||||
break
|
||||
}
|
||||
await Promise.race([delay(25), deadline])
|
||||
}
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [access.connection.id])).rows[0].count, 0)
|
||||
assert.equal(readCount, 1, "cancelled follow does not dispatch a second read")
|
||||
} finally {
|
||||
caller.abort()
|
||||
release()
|
||||
await refused.catch(() => undefined)
|
||||
}
|
||||
pool.query = originalQuery
|
||||
const from = "2000-01-01T00:00:00Z", to = "2000-01-01T00:30:00Z"
|
||||
const followOnce = (filters: { from?: string; cursor?: string }) => client.callTool({
|
||||
name: "mm_logs_follow", arguments: { serverId: 1, waitSeconds: 0, to, ...filters },
|
||||
})
|
||||
const initial = await followOnce({ from })
|
||||
assert.ok(!initial.isError)
|
||||
const cursor = (initial.structuredContent as { result?: { cursor?: unknown } } | undefined)?.result?.cursor
|
||||
assert.ok(typeof cursor === "string" && cursor.length > 0)
|
||||
assert.ok(!(await followOnce({ from, cursor })).isError)
|
||||
assert.ok(!(await followOnce({ cursor })).isError, "omitting from must resume the historical cursor's period")
|
||||
const changed = await followOnce({ from: "2000-01-01T00:01:00Z", cursor })
|
||||
assert.equal(changed.isError, true)
|
||||
assert.match(JSON.stringify(changed), /RESET_REQUIRED/)
|
||||
assert.doesNotMatch(JSON.stringify(changed), /"cursor"/)
|
||||
console.log("✓ MCP logs follow: historical cursor resumes, changed from requires reset")
|
||||
|
||||
const marker = `mcp-logs-cursor-${randomUUID()}`
|
||||
const readFollowPage = async (options: { cursor?: string; limit?: number }) => {
|
||||
const result = await client.callTool({ name: "mm_logs_follow", arguments: { serverId: 1, from, to, q: marker, waitSeconds: 0, ...options } })
|
||||
assert.ok(!result.isError)
|
||||
return (result.structuredContent as { result: { entries: { id: string }[]; cursor: string } }).result
|
||||
}
|
||||
const start = await readFollowPage({})
|
||||
assert.deepEqual(start.entries, [])
|
||||
try {
|
||||
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
|
||||
SELECT 1,'cursor fixture','127.0.0.1','fixture','00:02:00',now(),$1::timestamptz+n*interval '2 minutes',ARRAY['system'],'info',$2,0
|
||||
FROM generate_series(1,2) AS n`, [from, marker])
|
||||
const defaultFollow = await readFollowPage({ cursor: start.cursor })
|
||||
assert.equal(defaultFollow.entries.length, 2)
|
||||
const firstFollow = await readFollowPage({ cursor: start.cursor, limit: 1 })
|
||||
const secondFollow = await readFollowPage({ cursor: firstFollow.cursor, limit: 1 })
|
||||
assert.equal(firstFollow.entries.length, 1)
|
||||
assert.equal(secondFollow.entries.length, 1)
|
||||
assert.deepEqual([...firstFollow.entries, ...secondFollow.entries].map(row => row.id), defaultFollow.entries.map(row => row.id))
|
||||
assert.deepEqual((await readFollowPage({ cursor: secondFollow.cursor, limit: 1 })).entries, [])
|
||||
console.log("✓ MCP logs follow: requested batch limit and lossless cursor progression")
|
||||
const query = (filters: { from?: string; cursor?: string }) => client.callTool({
|
||||
name: "mm_logs_query", arguments: { serverId: 1, q: marker, limit: 1, to, ...filters },
|
||||
})
|
||||
type Page = { entries: { id: string }[]; nextCursor: string | null }
|
||||
const first = await query({ from })
|
||||
assert.ok(!first.isError)
|
||||
const firstPage = (first.structuredContent as { result: Page }).result
|
||||
assert.equal(firstPage.entries.length, 1)
|
||||
assert.ok(firstPage.nextCursor)
|
||||
const next = await query({ cursor: firstPage.nextCursor })
|
||||
assert.ok(!next.isError)
|
||||
const nextPage = (next.structuredContent as { result: Page }).result
|
||||
assert.equal(nextPage.entries.length, 1)
|
||||
assert.notEqual(nextPage.entries[0].id, firstPage.entries[0].id)
|
||||
assert.equal(nextPage.nextCursor, null)
|
||||
console.log("✓ MCP logs query: historical pagination resumes without resending from")
|
||||
} finally {
|
||||
await pool.query("DELETE FROM router_log_entries WHERE server_id=1 AND message=$1", [marker])
|
||||
}
|
||||
} finally {
|
||||
bound.abort()
|
||||
pool.query = originalQuery
|
||||
app.server.off("request", observeClose)
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export async function registerManagementChannel(raw: unknown, actor: AuthUser) {
|
||||
})
|
||||
return { id, serverId: input.serverId, expiresAt: input.expiresAt, verification: "identity-and-reachability", independence: "human-attestation" }
|
||||
}
|
||||
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
|
||||
async function verifiedManagementChannel(serverId: number, requiredId?: string) {
|
||||
const { rows: [row] } = await pool.query("SELECT * FROM mcp_management_channels WHERE server_id=$1 AND revoked_at IS NULL AND expires_at>now()", [serverId])
|
||||
if (!row || requiredId && row.id !== requiredId || row.generation !== await accessGeneration()) throw new McpError("APPROVAL_REQUIRED", "Нет актуального проверенного независимого канала управления", 409)
|
||||
const config = JSON.parse(await decryptSecret(row.config_sealed)) as MikrotikConnectParams
|
||||
@@ -50,7 +50,17 @@ export async function verifyManagementChannel(serverId: number, requiredId?: str
|
||||
if (hashes.some(value => value !== row.identity_hash)) throw new McpError("STATE_CONFLICT", "Идентичность основного или management-канала изменилась", 409)
|
||||
const changed = await pool.query("UPDATE mcp_management_channels SET verified_at=now() WHERE id=$1 AND revoked_at IS NULL AND expires_at>now() RETURNING id", [row.id])
|
||||
if (!changed.rowCount) throw new McpError("APPROVAL_REQUIRED", "Допуск management-канала был отозван во время проверки", 409)
|
||||
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string }
|
||||
return { channelId: row.id as string, serverId, expiresAt: row.expires_at.toISOString() as string, host: config.host }
|
||||
}
|
||||
export async function verifyManagementChannel(serverId: number, requiredId?: string) {
|
||||
const { host: _host, ...receipt } = await verifiedManagementChannel(serverId, requiredId)
|
||||
return receipt
|
||||
}
|
||||
/** Internal VLAN preparation uses the verified endpoint to trace its actual
|
||||
* address/interface. It is not accepted from tool input or returned in the
|
||||
* public channel receipt. */
|
||||
export async function verifyBridgeManagementChannel(serverId: number, requiredId?: string) {
|
||||
return verifiedManagementChannel(serverId, requiredId)
|
||||
}
|
||||
export async function managementEvidence(serverIds: number[]) {
|
||||
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,7 +11,7 @@ const creates = rosResources.filter(resource => !resource.readOnly && resource.c
|
||||
assert.equal(creates.length, 30)
|
||||
for (const resource of creates) {
|
||||
const action = findAction(`mm_${resource.id}_create_plan`)
|
||||
assert.equal(action.operation.version, ["ospf_template", "ipsec_peer"].includes(resource.id) ? 5 : ["wireguard", "vrf"].includes(resource.id) ? 4 : 3)
|
||||
assert.equal(action.operation.version, ["ospf_template", "ipsec_peer", "vrf"].includes(resource.id) ? 5 : ["wireguard", "bridge", "bridge_port", "bridge_vlan"].includes(resource.id) ? 4 : 3)
|
||||
assert.equal(action.schema.safeParse({ serverId: 1, fields: {} }).success, true)
|
||||
for (const id of ["*OLD", undefined, null, ""]) {
|
||||
const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id })
|
||||
|
||||
@@ -32,13 +32,13 @@ 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")
|
||||
assert.equal(findAction("mm_routing_measurement_plan").operation.version, 2)
|
||||
assert.equal(findAction("mm_operation_recovery_plan").operation.version, 7)
|
||||
assert.equal(findAction("mm_operation_recovery_plan").operation.version, 8)
|
||||
assert.equal(findAction("mm_ospf_template_create_plan").operation.version, 5)
|
||||
assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4)
|
||||
assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3)
|
||||
@@ -49,17 +49,17 @@ assert.equal(findAction("mm_wireguard_update_plan").operation.version, 3)
|
||||
assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3)
|
||||
assert.equal(findAction("mm_wireguard_interface_create_plan").operation.version, 3)
|
||||
assert.equal(findAction("mm_wireguard_create_plan").operation.version, 4)
|
||||
assert.equal(findAction("mm_interfaces_update_plan").operation.version, 4)
|
||||
assert.equal(findAction("mm_interfaces_update_plan").operation.version, 5)
|
||||
assert.equal(findAction("mm_ipsec_peer_delete_plan").operation.version, 3)
|
||||
assert.equal(findAction("mm_ipsec_peer_update_plan").operation.version, 3)
|
||||
assert.equal(findAction("mm_certificate_delete_plan").operation.version, 3)
|
||||
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 7], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 3], ["mm_firewall_rule_update_plan", 2]] as const) {
|
||||
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 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){
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
||||
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
||||
import { fingerprint, McpError, redact } from "./security.js"
|
||||
import { createRosPrefix } from "./ros-prefix.js"
|
||||
import { ROS_SNAPSHOT_VERSION, type RosSnapshot } from "./resources.js"
|
||||
import { rosSnapshotVersion, type RosSnapshot } from "./resources.js"
|
||||
|
||||
const ids = [1, 2, 3]
|
||||
const graph = { family: "ipv4" as const, sourceSecretProofs: ids.map(serverId => ({ serverId, proof: fingerprint({ serverId }) })), instances: ids.map(serverId => ({ serverId, fingerprint: fingerprint({ serverId, native: "instances" }) })) }
|
||||
@@ -54,7 +54,7 @@ const catalogAuthorize = async () => {
|
||||
await sourceGuard()
|
||||
}
|
||||
const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }]
|
||||
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof: "a".repeat(64) }
|
||||
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: rosSnapshotVersion("bridge"), sourceProof: "a".repeat(64) }
|
||||
reads = []
|
||||
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
|
||||
// Match readRosSnapshot's normal three grant/source fences without polling a
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod"
|
||||
export const pathProofPurposeSchema = z.enum(["community", "control-path"])
|
||||
export type PathProofPurpose = z.infer<typeof pathProofPurposeSchema>
|
||||
export const PATH_PROOF_VERSION = 2
|
||||
export const COMMUNITY_PATH_MAX_AGE_MS = 90000
|
||||
export const pathProofTag = (purpose: PathProofPurpose) => "path-check:" + purpose + ":v" + PATH_PROOF_VERSION + ":"
|
||||
export function isCommunityPathEvidence(value: Record<string, unknown>) {
|
||||
return value.proofPurpose === "community" && value.proofVersion === PATH_PROOF_VERSION
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fingerprint, McpError } from "./security.js"
|
||||
import { readRouteCommunityContext, routeContextAccess, type RouteContextOptions } from "./routing-community-context.js"
|
||||
import type { add } from "./actions.js"
|
||||
import { readControlPathContext } from "./control-path-context.js"
|
||||
import { isCommunityPathEvidence, PATH_PROOF_VERSION, pathProofPurposeSchema, type PathProofPurpose } from "./path-proof-purpose.js"
|
||||
import { COMMUNITY_PATH_MAX_AGE_MS, isCommunityPathEvidence, PATH_PROOF_VERSION, pathProofPurposeSchema, type PathProofPurpose } from "./path-proof-purpose.js"
|
||||
|
||||
export function inspectControlPing(rows: Parameters<typeof parsePing>[0]) {
|
||||
const parsed = parsePing(rows)
|
||||
@@ -107,10 +107,10 @@ export function registerPathActions(register: typeof add) {
|
||||
export async function requireRecentPath(profile: RouteAiProfile, candidateId: string, connectionId: string, options: RouteContextOptions = {}) {
|
||||
// Compare the database timestamp with the database clock. The signed
|
||||
// observedAt below is independently checked against the application clock.
|
||||
const { rows: [row] } = await pool.query("SELECT id,status,created_at,evidence,created_at BETWEEN clock_timestamp()-interval '90 seconds' AND clock_timestamp() AS database_fresh FROM mcp_path_checks WHERE connection_id=$1 AND profile_hash=$2 AND candidate_id=$3 AND evidence->>'proofPurpose'='community' AND evidence->>'proofVersion'='2' ORDER BY created_at DESC,id DESC LIMIT 1", [connectionId, fingerprint(profile), candidateId])
|
||||
const { rows: [row] } = await pool.query("SELECT id,status,created_at,evidence,created_at BETWEEN clock_timestamp()-$4::int * interval '1 millisecond' AND clock_timestamp() AS database_fresh FROM mcp_path_checks WHERE connection_id=$1 AND profile_hash=$2 AND candidate_id=$3 AND evidence->>'proofPurpose'='community' AND evidence->>'proofVersion'='2' ORDER BY created_at DESC,id DESC LIMIT 1", [connectionId, fingerprint(profile), candidateId, COMMUNITY_PATH_MAX_AGE_MS])
|
||||
if (!row || row.status !== "passed" || row.database_fresh !== true) throw new McpError("MEASUREMENT_UNKNOWN", "Нет свежего подтверждения контрольного пути", 409)
|
||||
const { proof, ...evidence } = row.evidence ?? {}
|
||||
if (!isCommunityPathEvidence(evidence) || evidence.id !== row.id || evidence.connectionId !== connectionId || evidence.profileHash !== fingerprint(profile) || evidence.candidateId !== candidateId || evidence.status !== "passed" || !Array.isArray(evidence.sourceProofs) || !evidence.sourceProofs.length || !Number.isFinite(Date.parse(evidence.observedAt)) || Date.now() - Date.parse(evidence.observedAt) > 90000 || Date.parse(evidence.observedAt) > Date.now()) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути не связано с Community purpose, источником и контекстом", 409)
|
||||
if (!isCommunityPathEvidence(evidence) || evidence.id !== row.id || evidence.connectionId !== connectionId || evidence.profileHash !== fingerprint(profile) || evidence.candidateId !== candidateId || evidence.status !== "passed" || !Array.isArray(evidence.sourceProofs) || !evidence.sourceProofs.length || !Number.isFinite(Date.parse(evidence.observedAt)) || Date.now() - Date.parse(evidence.observedAt) > COMMUNITY_PATH_MAX_AGE_MS || Date.parse(evidence.observedAt) > Date.now()) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути не связано с Community purpose, источником и контекстом", 409)
|
||||
const current = await readRouteCommunityContext(profile, { ...options, selectedCandidateId: candidateId, expectedSources: evidence.sourceProofs })
|
||||
if (!current.verifyEvidence(evidence, proof)) throw new McpError("MEASUREMENT_UNKNOWN", "Зависимости пути или сохранённое подтверждение изменились", 409)
|
||||
return row.id as string
|
||||
|
||||
@@ -5,6 +5,14 @@ import { preparedPlanExpiry } from "./plan-expiry.js"
|
||||
const iso = (milliseconds: number) => new Date(Date.now() + milliseconds).toISOString()
|
||||
const poll = (validUntil?: string) => preparedPlanExpiry("servers.poll", "servers", { validUntil })
|
||||
|
||||
test("Community honors sealed validity but keeps the ordinary lifetime without a bound", () => {
|
||||
const bound = iso(90000)
|
||||
assert.equal(preparedPlanExpiry("routing.community.switch", "network", { validUntil: bound })?.toISOString(), bound)
|
||||
assert.equal(preparedPlanExpiry("routing.community.switch", "network", { validUntil: iso(-1000) }), null)
|
||||
const before = Date.now(), manual = preparedPlanExpiry("routing.community.switch", "network", {})!.getTime()
|
||||
assert(manual >= before + 300000 && manual <= Date.now() + 300000)
|
||||
})
|
||||
|
||||
test("Poll uses its existing sealed five-minute validity as the nominal window", () => {
|
||||
const bound = iso(300000)
|
||||
assert.equal(poll(bound)?.toISOString(), bound)
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { ChangeEvidence } from "./action-types.js"
|
||||
|
||||
/** Internal value calculation only; evidence comes from action.prepare, never
|
||||
* caller JSON. Generic plans may outlive their preparation access credential.
|
||||
* A missing result makes the runtime refuse Poll before storing a new plan. */
|
||||
* A missing result refuses a time-bound plan before storage. */
|
||||
export function preparedPlanExpiry(operationId: string, section: string, evidence: Pick<ChangeEvidence, "validUntil">): Date | null {
|
||||
const now = Date.now(), ordinaryExpiry = now + (section === "network" ? 300000 : 900000)
|
||||
if (operationId !== "servers.poll") return new Date(ordinaryExpiry)
|
||||
const validity = z.string().datetime({ offset: true }).safeParse(evidence.validUntil)
|
||||
if (operationId !== "servers.poll" && operationId !== "routing.community.switch") return new Date(ordinaryExpiry)
|
||||
const validUntil = evidence.validUntil
|
||||
if (operationId === "routing.community.switch" && validUntil === undefined) return new Date(ordinaryExpiry)
|
||||
const validity = z.string().datetime({ offset: true }).safeParse(validUntil)
|
||||
const bound = validity.success ? Date.parse(validity.data) : NaN
|
||||
if (!Number.isFinite(bound) || bound <= now) return null
|
||||
return new Date(Math.min(ordinaryExpiry, bound))
|
||||
|
||||
@@ -21,7 +21,7 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
|
||||
try {
|
||||
for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) {
|
||||
mode = state
|
||||
const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", 300)
|
||||
const started = performance.now(), result = await runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 300 })
|
||||
assert.equal(result.status, "failed", state)
|
||||
assert.ok(performance.now() - started < 3000, state)
|
||||
assert.ok(!JSON.stringify(result).includes("private-readiness-fixture"))
|
||||
@@ -29,3 +29,49 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
|
||||
assert.equal(forwarded, 0); assert.ok(requests >= 5)
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
})
|
||||
|
||||
test("capabilities readiness refuses widened scope and checks the tool result", async () => {
|
||||
const capability = { id: "capabilities.read", toolName: "mm_capabilities_get" }
|
||||
const tool = { name: capability.toolName, inputSchema: { type: "object" } }
|
||||
let mode = "extra-tool", calls = 0
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method !== "POST") { res.writeHead(405).end(); return }
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const { id, method, params } = JSON.parse(Buffer.concat(chunks).toString())
|
||||
if (id === undefined) { res.writeHead(202).end(); return }
|
||||
let result: unknown
|
||||
if (method === "initialize") result = { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1.0.0" } }
|
||||
if (method === "tools/list") result = { tools: mode === "extra-tool" ? [tool, { ...tool, name: "mm_servers_list" }] : [tool] }
|
||||
if (method === "tools/call") {
|
||||
calls++
|
||||
assert.equal(params.name, capability.toolName)
|
||||
if (mode === "timeout") { res.writeHead(200, { "content-type": "application/json" }).write("{"); return }
|
||||
const operations = mode === "extra-operation" ? [capability, { id: "servers.list" }] : [capability]
|
||||
result = { content: [], isError: mode === "tool-error", structuredContent: { result: { schemaVersion: 1, operations } } }
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id, result }))
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const endpoint = new URL(`http://127.0.0.1:${(server.address() as { port: number }).port}/mcp`)
|
||||
const probe = () => runMcpProtocolProbe(endpoint, "private-readiness-fixture", { timeoutMs: 400, readCapabilities: true })
|
||||
try {
|
||||
const widened = await probe()
|
||||
assert.equal(widened.reason, "SCOPE_NOT_ENFORCED")
|
||||
assert.equal(widened.capabilitiesRead, "skipped")
|
||||
assert.equal(calls, 0)
|
||||
for (const state of ["tool-error", "extra-operation", "timeout"]) {
|
||||
mode = state
|
||||
const result = await probe()
|
||||
assert.equal(result.status, "failed", state)
|
||||
assert.equal(result.capabilitiesRead, "failed", state)
|
||||
assert.equal(result.reason, state === "timeout" ? "TIMEOUT" : "CAPABILITIES_READ_FAILED", state)
|
||||
}
|
||||
mode = "valid"
|
||||
const valid = await probe()
|
||||
assert.equal(valid.status, "passed")
|
||||
assert.equal(valid.capabilitiesRead, "passed")
|
||||
assert.equal(valid.clientCredentialsVerified, false)
|
||||
assert.equal(calls, 4)
|
||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||
})
|
||||
@@ -9,15 +9,19 @@ export interface ProtocolProbe {
|
||||
status: "passed" | "failed" | "skipped";
|
||||
elapsedMs: number;
|
||||
reason?: string;
|
||||
credentialType: "temporary-no-operations";
|
||||
credentialType: "temporary-no-operations" | "temporary-capabilities-read";
|
||||
capabilitiesRead: "passed" | "failed" | "skipped";
|
||||
clientCredentialsVerified: false;
|
||||
}
|
||||
|
||||
/** A temporary access credential may negotiate metadata, but its empty grant
|
||||
* permits no tools. The ordinary connection credentials are never recovered. */
|
||||
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string) => Promise<T>): Promise<T> {
|
||||
const capabilitiesOperation = "capabilities.read"
|
||||
const capabilitiesTool = "mm_capabilities_get"
|
||||
|
||||
/** The probe may read its own narrowed capabilities when the connection grants
|
||||
* that operation. The ordinary connection credentials are never recovered. */
|
||||
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string, readCapabilities: boolean) => Promise<T>): Promise<T> {
|
||||
const credentialId = randomUUID(), grantId = randomUUID(), token = randomToken("access")
|
||||
await transaction(async client => {
|
||||
const readCapabilities = await transaction(async client => {
|
||||
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [connectionId])
|
||||
const connection = await getConnection(connectionId, client)
|
||||
assertConnectionVisible(connection, actor)
|
||||
@@ -27,14 +31,16 @@ export async function withMcpProbeCredential<T>(connectionId: string, actor: Aut
|
||||
const expired = (await client.query("DELETE FROM mcp_credentials WHERE connection_id=$1 AND client_id='mcp-readiness-probe' AND expires_at<=now() RETURNING id", [connectionId])).rows.map(row => row.id)
|
||||
if (expired.length) await client.query("DELETE FROM mcp_grants WHERE connection_id=$1 AND payload->>'credentialId'=ANY($2::text[])", [connectionId, expired])
|
||||
const expiresAt = new Date(Math.min(Date.now() + 20000, Date.parse(connection.policy.expiresAt), connection.owner_valid_until.getTime())).toISOString()
|
||||
const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: [], allowGlobal: false } })
|
||||
const allowed = connection.policy.scope.operationIds.includes(capabilitiesOperation)
|
||||
const grant = mcpTemporaryGrantSchema.parse({ id: grantId, startsAt: new Date(Date.now() - 1000).toISOString(), expiresAt, scope: { serverIds: [], operationIds: allowed ? [capabilitiesOperation] : [], allowGlobal: false } })
|
||||
await client.query("INSERT INTO mcp_credentials(id,connection_id,digest,kind,expires_at,audience,client_id) VALUES($1,$2,$3,'access',$4,$5,'mcp-readiness-probe')", [credentialId, connectionId, tokenDigest(token), expiresAt, canonicalMcpUrl().href])
|
||||
await client.query("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)", [grantId, connectionId, JSON.stringify({ ...grant, credentialId })])
|
||||
await validatePrincipal({ connectionId, credentialId, credentialExpiresAt: expiresAt }, client)
|
||||
await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt })
|
||||
return allowed
|
||||
})
|
||||
let completed = false
|
||||
try { const result = await work(token); completed = true; return result }
|
||||
try { const result = await work(token, readCapabilities); completed = true; return result }
|
||||
finally {
|
||||
await transaction(async client => {
|
||||
await client.query("DELETE FROM mcp_grants WHERE id=$1 AND connection_id=$2", [grantId, connectionId])
|
||||
@@ -45,11 +51,12 @@ export async function withMcpProbeCredential<T>(connectionId: string, actor: Aut
|
||||
}
|
||||
|
||||
/** One deadline covers negotiation, metadata and HTTP response bodies. */
|
||||
export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutMs = 8000): Promise<ProtocolProbe> {
|
||||
export async function runMcpProtocolProbe(endpoint: URL, token: string, { timeoutMs = 8000, readCapabilities = false } = {}): Promise<ProtocolProbe> {
|
||||
const started = performance.now(), controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
const client = new Client({ name: "MikrotikManager readiness", version: "1.0.0" })
|
||||
const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: "temporary-no-operations", clientCredentialsVerified: false })
|
||||
let capabilitiesRead: ProtocolProbe["capabilitiesRead"] = "skipped"
|
||||
const result = (status: ProtocolProbe["status"], reason?: string): ProtocolProbe => ({ status, reason, elapsedMs: Math.round(performance.now() - started), credentialType: readCapabilities ? "temporary-capabilities-read" : "temporary-no-operations", capabilitiesRead, clientCredentialsVerified: false })
|
||||
try {
|
||||
const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow",
|
||||
fetch: async (url, init) => {
|
||||
@@ -67,13 +74,22 @@ export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutM
|
||||
} })
|
||||
await client.connect(transport, { timeout: timeoutMs })
|
||||
const tools = await client.listTools({}, { timeout: timeoutMs })
|
||||
if (tools.tools.length !== 0 || tools.nextCursor) return result("failed", "SCOPE_NOT_ENFORCED")
|
||||
const expectedTools = readCapabilities ? [capabilitiesTool] : []
|
||||
if (tools.nextCursor || tools.tools.length !== expectedTools.length || tools.tools.some((tool, index) => tool.name !== expectedTools[index])) return result("failed", "SCOPE_NOT_ENFORCED")
|
||||
if (readCapabilities) {
|
||||
capabilitiesRead = "failed"
|
||||
const call = await client.callTool({ name: capabilitiesTool, arguments: {} }, { timeout: timeoutMs })
|
||||
const output = (call.structuredContent as { result?: { schemaVersion?: unknown; operations?: unknown } } | undefined)?.result
|
||||
const operations = output?.operations
|
||||
if (call.isError || output?.schemaVersion !== 1 || !Array.isArray(operations) || operations.length !== 1 || operations[0]?.id !== capabilitiesOperation || operations[0]?.toolName !== capabilitiesTool) return result("failed", "CAPABILITIES_READ_FAILED")
|
||||
capabilitiesRead = "passed"
|
||||
}
|
||||
return result("passed")
|
||||
} catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : "TRANSPORT_FAILED") }
|
||||
} catch { return result("failed", controller.signal.aborted ? "TIMEOUT" : capabilitiesRead === "failed" ? "CAPABILITIES_READ_FAILED" : "TRANSPORT_FAILED") }
|
||||
finally { clearTimeout(timer); controller.abort(); await client.close().catch(() => {}) }
|
||||
}
|
||||
|
||||
export async function checkMcpProtocol(connectionId: string, actor: AuthUser) {
|
||||
const endpoint = canonicalMcpUrl()
|
||||
return withMcpProbeCredential(connectionId, actor, token => runMcpProtocolProbe(endpoint, token))
|
||||
return withMcpProbeCredential(connectionId, actor, (token, readCapabilities) => runMcpProtocolProbe(endpoint, token, { readCapabilities }))
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||
import { effectiveMcpConnection } from "./store.js"
|
||||
import { findMcpService } from "./service-registry.js"
|
||||
import { findMcpOperation } from "./registry.js"
|
||||
import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { readRosSnapshot, resourceById, routerClient, validateRosStep, splitOspfPassiveUpdate, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||
import type { add } from "./actions.js"
|
||||
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
||||
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
||||
import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js"
|
||||
import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js"
|
||||
import { needsBridgeVlanRecovery, prepareBridgeVlanRecovery } from "./bridge-vlan-recovery.js"
|
||||
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])
|
||||
@@ -86,7 +89,7 @@ export function registerRecoveryActions(register: typeof add) {
|
||||
},
|
||||
})
|
||||
register("operations.recover", "mm_operation_recovery_plan", "Подготовить отдельный обратный план сетевой операции", "network", "mm:network:write", "write", "servers", z.object({ operationId: z.string().uuid() }).strict(), {
|
||||
version: 7,
|
||||
version: 8,
|
||||
target: async (input, context) => {
|
||||
const { evidence } = await source(String(input.operationId), context, true)
|
||||
if (evidence.steps.some(step => step.kind === "ros" && step.step.resource === "vrf")) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF не имеет проверенной обратной ревизии; используйте read-only сверку и отдельный approved план", 409)
|
||||
@@ -106,14 +109,16 @@ export function registerRecoveryActions(register: typeof add) {
|
||||
const checkpoints = (await pool.query("SELECT step_index,before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
||||
const intended = (await pool.query("SELECT step_index,state,result FROM mcp_operation_steps WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
||||
if (!intended.length || intended.some(step => step.state !== "verified") || intended.length !== checkpoints.length || checkpoints.some(checkpoint => !checkpoint.after_sealed)) throw new McpError("STATE_CONFLICT", "Нет полного набора подтверждённых снимков; требуется ручная сверка", 409)
|
||||
if (needsBridgeVlanRecovery(row.source_action, evidence)) return prepareBridgeVlanRecovery({ operationId: row.id, evidence, checkpoints, intended }, context)
|
||||
const snapshots = new Map<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)
|
||||
const step = original.step, resource = resourceById(step.resource)
|
||||
if (!context.connection.policy.scope.serverIds.includes(step.serverId)) throw new McpError("SERVER_DENIED", "Зависимость вне допуска")
|
||||
const before = JSON.parse(await decryptSecret(checkpoint.before_sealed)) as RosSnapshot, after = JSON.parse(await decryptSecret(checkpoint.after_sealed)) as RosSnapshot
|
||||
if (before.projectionVersion !== ROS_SNAPSHOT_VERSION || after.projectionVersion !== ROS_SNAPSHOT_VERSION || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409)
|
||||
if (before.projectionVersion !== rosSnapshotVersion(step.resource) || after.projectionVersion !== rosSnapshotVersion(step.resource) || !before.sourceProof || before.sourceProof !== after.sourceProof || before.fingerprint !== fingerprint(before.rows) || after.fingerprint !== fingerprint(after.rows) || !verifyRosStepSnapshots(step, intended.find(item => item.step_index === checkpoint.step_index)?.result, before, after)) throw new McpError("STATE_CONFLICT", "Исходный переход каталога или источник не подтверждён; обратный план запрещён", 409)
|
||||
const key = `${step.serverId}:${step.resource}`
|
||||
if (!snapshots.has(key)) {
|
||||
const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof)
|
||||
@@ -143,11 +148,35 @@ export function registerRecoveryActions(register: typeof add) {
|
||||
const key = `${dependency.serverId}:${dependency.resource}`
|
||||
if (snapshots.has(key)) continue
|
||||
const current = await readRosSnapshot(dependency.serverId, dependency.resource, undefined, dependency.sourceProof)
|
||||
if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== ROS_SNAPSHOT_VERSION) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409)
|
||||
if (current.fingerprint !== dependency.fingerprint || current.sourceProof !== dependency.sourceProof || dependency.projectionVersion !== rosSnapshotVersion(dependency.resource)) throw new McpError("STATE_CONFLICT", "Зависимость или источник исходного плана изменились", 409)
|
||||
snapshots.set(key, current)
|
||||
}
|
||||
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 }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,14 +5,19 @@ import { eq } from "drizzle-orm"
|
||||
import { db } from "../../db/index.js"
|
||||
import { servers } from "../../db/schema.js"
|
||||
import { MikrotikClient } from "../../services/mikrotik.js"
|
||||
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
|
||||
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.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"
|
||||
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||
|
||||
const text = z.string().max(1000)
|
||||
const name = z.string().trim().min(1).max(100)
|
||||
const bool = z.boolean()
|
||||
const vlanIds = z.string().min(1).max(16384).refine(value => {
|
||||
try { canonicalBridgeVlanValue("vlan-ids", value); return true } catch { return false }
|
||||
}, "Требуется непересекающийся список VLAN ID/диапазонов 1..4094")
|
||||
const integer = (min: number, max: number) => z.number().int().min(min).max(max)
|
||||
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
|
||||
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
|
||||
@@ -31,19 +36,21 @@ const route = {
|
||||
export interface RosResourceSpec {
|
||||
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
|
||||
create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean
|
||||
/** Native configuration sealed for drift checks, never accepted as writes. */
|
||||
observeFields?: readonly string[]
|
||||
}
|
||||
export const rosResources: RosResourceSpec[] = [
|
||||
{ id: "routing_filter", path: "/routing/filter/rule", title: "Фильтры маршрутизации", permission: "mm:filters", fields: z.object({ chain: text.optional(), rule: text.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: false, remove: false, readOnly: true },
|
||||
{ id: "ethernet", path: "/interface/ethernet", title: "Ethernet", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "auto-negotiation": bool.optional() }).strict(), create: false, remove: false },
|
||||
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
|
||||
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), "ingress-filtering": bool.optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), observeFields: ["mvrp"], create: true, remove: true, requiredOnCreate: ["name"] },
|
||||
{ id: "bridge_port", path: "/interface/bridge/port", title: "Порты bridge", permission: "mm:network", fields: z.object({ bridge: name.optional(), interface: name.optional(), pvid: integer(1, 4094).optional(), "ingress-filtering": bool.optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "interface"] },
|
||||
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": name.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
|
||||
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": vlanIds.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
|
||||
{ id: "vlan", path: "/interface/vlan", title: "VLAN", permission: "mm:network", fields: z.object({ ...common, interface: name.optional(), "vlan-id": integer(1, 4094).optional(), mtu: integer(68, 65535).optional(), "use-service-tag": bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interface", "vlan-id"] },
|
||||
{ id: "ipv4_address", path: "/ip/address", title: "IPv4-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), network: name.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
||||
{ id: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
||||
{ 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 },
|
||||
@@ -70,7 +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", "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)
|
||||
@@ -93,13 +127,21 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown):
|
||||
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
|
||||
// They cannot be configured independently and must not invalidate a sealed
|
||||
// inverse plan when its own GRE is disabled. Explicit addresses remain sealed.
|
||||
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address))))
|
||||
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address)))
|
||||
// These rows are generated from bridge/port PVID, not independently
|
||||
// configurable VLAN entries. Their inputs remain sealed in bridge/ports.
|
||||
&& !(resource.id === "bridge_vlan" && [true, "true", "yes"].includes((row as ConfigurationRow).dynamic as boolean | string)))
|
||||
const selectedRows = configuration.map(row => {
|
||||
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields].includes(key)))
|
||||
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields, ...(resource.observeFields ?? [])].includes(key)))
|
||||
// RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables.
|
||||
// Normalize only this observed field; missing values must remain unknown.
|
||||
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
|
||||
if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces)
|
||||
if (["bridge_port", "bridge_vlan"].includes(resource.id) && selected.comment === undefined) selected.comment = ""
|
||||
if (resource.id === "bridge_vlan") {
|
||||
try { for (const field of ["vlan-ids", "tagged", "untagged"]) selected[field] = canonicalBridgeVlanValue(field, selected[field]) }
|
||||
catch { throw new McpError("STATE_CONFLICT", "Недостоверная конфигурация VLAN membership", 409) }
|
||||
}
|
||||
return selected
|
||||
})
|
||||
// Reordering an ordered catalog changes configuration even with unchanged rows.
|
||||
@@ -107,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 () => {
|
||||
@@ -118,10 +161,10 @@ export async function readRosSnapshot(serverId: number, resourceId: string, befo
|
||||
const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get<unknown>(resource.path), async id => {
|
||||
await checkSource()
|
||||
return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" })
|
||||
}) : await client.get<unknown>(resource.path)
|
||||
}) : await readRouterOsSnapshotGet<unknown>({ client, path: resource.path, assertCurrent: checkSource })
|
||||
await checkSource()
|
||||
const rows = configurationRows(resource, payload)
|
||||
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof }
|
||||
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> {
|
||||
return privateSecretFingerprint(fingerprint({ id: server.id, enabled: server.enabled, host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, epoch: server.routingEndpointEpoch }), "mcp-ros-snapshot-source-v3")
|
||||
|
||||
@@ -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,11 +1,11 @@
|
||||
import { fingerprint, McpError } from "./security.js"
|
||||
import { readRosSnapshot, ROS_SNAPSHOT_VERSION, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { readRosSnapshot, rosSnapshotVersion, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||
import { acknowledgedCreateId } from "./native-create-identity.js"
|
||||
|
||||
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
||||
function valid(snapshot: RosSnapshot): boolean {
|
||||
if (snapshot.projectionVersion !== ROS_SNAPSHOT_VERSION || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
|
||||
if (snapshot.projectionVersion !== rosSnapshotVersion(snapshot.resource) || typeof snapshot.sourceProof !== "string" || !/^[a-f0-9]{64}$/.test(snapshot.sourceProof) || !Array.isArray(snapshot.rows) || snapshot.fingerprint !== fingerprint(snapshot.rows)) return false
|
||||
if (["ip_settings", "ipv6_settings"].includes(snapshot.resource)) return snapshot.rows.length === 1 && !!snapshot.rows[0] && typeof snapshot.rows[0] === "object" && !Array.isArray(snapshot.rows[0])
|
||||
const ids = snapshot.rows.map(acknowledgedCreateId)
|
||||
return ids.every(id => id !== undefined) && new Set(ids).size === ids.length
|
||||
|
||||
@@ -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,6 +1,8 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { fingerprint } from "./security.js"
|
||||
import { acknowledgedCreateId } from "./native-create-identity.js"
|
||||
import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
|
||||
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.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
|
||||
@@ -23,6 +25,9 @@ function requested(resource: RosResourceSpec, fields: RosStep["fields"], row: Co
|
||||
const schema = resource.fields.shape[key]
|
||||
if (!schema || typeof value !== "string" || !Object.hasOwn(row, key) || row[key] === undefined || row[key] === null) return false
|
||||
if (schema.safeParse(true).success) return ["true", "false"].includes(value) && booleanValue(row[key]) === (value === "true")
|
||||
if (resource.id === "bridge_vlan" && ["vlan-ids", "tagged", "untagged"].includes(key)) {
|
||||
try { return canonicalBridgeVlanValue(key, row[key]) === canonicalBridgeVlanValue(key, value) } catch { return false }
|
||||
}
|
||||
return String(row[key]) === value
|
||||
})
|
||||
}
|
||||
@@ -88,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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,17 @@ 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(`WITH interrupted AS (
|
||||
SELECT intent.source_operation_id,intent.source_step_index
|
||||
FROM mcp_scoped_recovery_intents intent
|
||||
WHERE intent.state='intent' AND (intent.deadline<=now() OR EXISTS (
|
||||
SELECT 1 FROM mcp_operations operation
|
||||
WHERE operation.id=intent.recovery_operation_id AND operation.state NOT IN ('pending','running')
|
||||
))
|
||||
ORDER BY intent.deadline,intent.source_operation_id,intent.source_step_index
|
||||
LIMIT 1000 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE mcp_scoped_recovery_intents intent SET state='unknown',finished_at=now()
|
||||
FROM interrupted WHERE intent.source_operation_id=interrupted.source_operation_id
|
||||
AND intent.source_step_index=interrupted.source_step_index`))
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { PoolClient } from "pg"
|
||||
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
|
||||
import { fingerprint, McpError, privateSecretFingerprint } from "./security.js"
|
||||
|
||||
export async function readScopedPathCheckpoint(db: Pick<PoolClient, "query">, id: string, connectionId: string, phase: "admission" | "configuration" = "admission") {
|
||||
const { rows: [row] } = await db.query("SELECT id,connection_id,profile_hash,candidate_id,status,created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [id, connectionId])
|
||||
const now = Date.now(), createdAt = row?.created_at.getTime(), observedAt = Date.parse(row?.evidence?.observedAt)
|
||||
const validUntil = Math.min(createdAt, observedAt) + COMMUNITY_PATH_MAX_AGE_MS
|
||||
if (!row || row.status !== "passed" || createdAt > now || phase === "admission" &&
|
||||
(!Number.isFinite(validUntil) || observedAt > now || validUntil <= now)) {
|
||||
throw new McpError("MEASUREMENT_UNKNOWN", "Контрольное подтверждение пути устарело", 409)
|
||||
}
|
||||
return { proof: await privateSecretFingerprint(fingerprint(row), "route-ai-scoped-path-checkpoint-v1"), validUntil }
|
||||
}
|
||||
|
||||
/** One request domain for forward execution and trusted recovery resolution. */
|
||||
export async function assertScopedCommunityRequest(input: Record<string, unknown>) {
|
||||
const { requestProof: _proof, ...request } = input
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoutingNetworkSessionForCurrentExecution } from "./routing-network-execution-session.js"
|
||||
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
|
||||
import { resolveRoutingNetworkForwardRoundConfigurationContext, closeRoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js"
|
||||
import { originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js"
|
||||
import { originalEffectReportSchema, originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { z } from "zod"
|
||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||
@@ -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 } from "./routing-community-scoped-request.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"
|
||||
@@ -37,12 +38,6 @@ const effectReadFailure = (stage: "original-effect-rounds-unavailable" | "effect
|
||||
export const scopedProfileIds = (profile: z.infer<typeof routeAiProfileSchema>) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a, b) => a - b)
|
||||
const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface })
|
||||
type ObservationPhase = "admission" | "configuration"
|
||||
async function pathCheckpoint(id: string, connectionId: string, phase: ObservationPhase = "admission") {
|
||||
const row = (await pool.query("SELECT id,connection_id,profile_hash,candidate_id,status,created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [id, connectionId])).rows[0]
|
||||
if (!row || row.status !== "passed" || row.created_at.getTime() > Date.now() || phase === "admission" && Date.now() - row.created_at.getTime() > 90000) throw fail("Контрольное подтверждение пути устарело", "MEASUREMENT_UNKNOWN")
|
||||
return privateSecretFingerprint(fingerprint(row), "route-ai-scoped-path-checkpoint-v1")
|
||||
}
|
||||
|
||||
async function access(input: Record<string, unknown>, signal?: AbortSignal, verified = false, phase: ObservationPhase = "admission") {
|
||||
if (signal?.aborted) throw fail("Операция отменена", "READ_CANCELLED")
|
||||
if (verified) await assertVerifiedConfigurationAccess()
|
||||
@@ -58,7 +53,7 @@ async function access(input: Record<string, unknown>, signal?: AbortSignal, veri
|
||||
const ranked = rankRoutingCandidates(profile, await readRoutingObservations(ids))
|
||||
if (!ranked.find(row => row.candidate.id === input.candidateId)?.eligible) throw fail("Нет свежего SLA выбранного пути", "MEASUREMENT_UNKNOWN")
|
||||
}
|
||||
if (input.pathCheckId !== undefined && await pathCheckpoint(String(input.pathCheckId), principal.connectionId, phase) !== input.pathCheckpointProof) throw fail("Подтверждение пути изменилось", "MEASUREMENT_UNKNOWN")
|
||||
if (input.pathCheckId !== undefined && (await readScopedPathCheckpoint(pool, String(input.pathCheckId), principal.connectionId, phase)).proof !== input.pathCheckpointProof) throw fail("Подтверждение пути изменилось", "MEASUREMENT_UNKNOWN")
|
||||
return { principal, current, profile, ids }
|
||||
}
|
||||
|
||||
@@ -90,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)
|
||||
@@ -117,7 +119,8 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
||||
await access(request, action.signal)
|
||||
}
|
||||
const pathCheckId = await requireRecentPath(profile, String(input.candidateId), action.connection.id, { authorize: async () => { await access(request, action.signal) }, signal: action.signal })
|
||||
const withPath = { ...request, pathCheckId, pathCheckpointProof: await pathCheckpoint(pathCheckId, action.connection.id) }
|
||||
const checkpoint = await readScopedPathCheckpoint(pool, pathCheckId, action.connection.id)
|
||||
const withPath = { ...request, pathCheckId, pathCheckpointProof: checkpoint.proof }
|
||||
const value = await observe(withPath, action.signal), owned = value.context.ownedTarget!
|
||||
if (!value.candidate.controlProbe) throw fail("Scoped переключение требует свежий control probe", "MEASUREMENT_UNKNOWN")
|
||||
await requireRecentPath(value.profile, value.candidate.id, action.connection.id, { authorize: value.authorize, signal: action.signal })
|
||||
@@ -130,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) {
|
||||
@@ -149,9 +152,12 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
||||
sealed.requestProof = await privateSecretFingerprint(fingerprint(sealed), `route-ai-scoped-request-v${sealed.proofRevision}`)
|
||||
await value.context.fence()
|
||||
await access(sealed, action.signal)
|
||||
const authorityValidUntil = persistent ? routingProfileAuthorityBindingSchema.parse(sealed.persistentProfileAuthority).packet.validUntil : undefined
|
||||
const validUntil = authorityValidUntil && !profile.autoRecovery
|
||||
? new Date(Math.min(Date.parse(authorityValidUntil), checkpoint.validUntil)).toISOString() : authorityValidUntil
|
||||
return { target: { scope: "servers", serverIds: scopedProfileIds(value.profile) }, objects: [{ kind: "routing_filter", serverId: value.profile.serverId, id: owned.nativeId }, ...scopedProfileIds(value.profile).map(serverId => ({ kind: "server" as const, serverId, id: String(serverId) }))], snapshots: [], before: sealed.beforeRules,
|
||||
steps: [{ kind: "service", service: "routing.community.scoped", input: sealed }], requiresHuman: true, mutationCost: 2, verificationScope: "configuration",
|
||||
...(persistent ? { validUntil: routingProfileAuthorityBindingSchema.parse(sealed.persistentProfileAuthority).packet.validUntil } : {}),
|
||||
...(validUntil ? { validUntil } : {}),
|
||||
warnings: ["Одна запись owned фильтра; SQL метаданные фильтров сохраняются.", "SLA и свежая preflight-проба обязательны до отправки PATCH. После записи подтверждение конфигурации сохраняется независимо от новых SLA-измерений; контрольная проба и её результат проверяются отдельно.", "Подтверждается конфигурация и control probe; сквозной и обратный трафик требуют отдельной приёмки.", managementBindingCiphertext ? "Заранее закреплён независимый транспорт для отдельного обратного плана; автоматический inverse не включён." : "Независимый transport не закреплён; отдельный обратный план потребует доступного primary.", "Неизвестный результат не повторяется; сверка только читает состояние."] }
|
||||
}
|
||||
|
||||
@@ -166,7 +172,9 @@ export function registerScopedCommunityService() {
|
||||
}
|
||||
await original(input, true)
|
||||
if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN")
|
||||
await receipt(input, "routing.scoped.attempt")
|
||||
const requiresEffectBaseline = input.proofRevision === 3 && !routeAiProfileSchema.parse(input.profile).autoRecovery
|
||||
let attemptRecorded = !requiresEffectBaseline
|
||||
if (attemptRecorded) await receipt(input, "routing.scoped.attempt")
|
||||
const before = await original(input, true), owned = before.context.ownedTarget!
|
||||
let effectSession: Awaited<ReturnType<typeof createRoutingNetworkSessionForCurrentExecution>> | undefined
|
||||
let effectBaseline: Parameters<typeof originalEffectBaselineMatches>[1] | undefined
|
||||
@@ -174,8 +182,8 @@ export function registerScopedCommunityService() {
|
||||
const effectSelection = originalEffectSelectionSchema.safeParse(input.originalEffectSelection)
|
||||
if (effectSelection.success) originalEffects = unknownOriginalEffects(effectSelection.data.status === "unknown" ? effectSelection.data.reason : "effect-acquisition-unavailable")
|
||||
try {
|
||||
// Optional original-effect vector is never an extra write/recovery grant.
|
||||
// Old manual configuration+probe acceptance remains authoritative.
|
||||
// The persistent controller needs a baseline it can advance after the
|
||||
// write. Manual plans still treat the effect vector as observational.
|
||||
if (context?.phase === "forward" && (input.proofRevision === 3 || input.proofRevision === 2 && input.manualEffectPolicy === "observational-v1") && effectSelection.success && effectSelection.data.status === "prepared") {
|
||||
try {
|
||||
const selected = effectSelection.data
|
||||
@@ -188,13 +196,18 @@ export function registerScopedCommunityService() {
|
||||
}
|
||||
} catch { effectSession?.close(); effectSession = undefined }
|
||||
}
|
||||
if (requiresEffectBaseline) {
|
||||
if (!effectSession) throw fail("Исходное состояние изменяемого префикса не подтверждено", "MEASUREMENT_UNKNOWN")
|
||||
await receipt(input, "routing.scoped.attempt")
|
||||
attemptRecorded = true
|
||||
}
|
||||
await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => {
|
||||
// This runs AFTER the durable configuration.intent, immediately before transport.
|
||||
const fresh = await original(input, true); await before.context.fence()
|
||||
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.
|
||||
@@ -208,8 +221,7 @@ export function registerScopedCommunityService() {
|
||||
roundContext = await resolveRoutingNetworkForwardRoundConfigurationContext(effectSession.sharedContext, action)
|
||||
const first = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
||||
const second = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
||||
const observed = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline), budget = effectSession.readBudget()
|
||||
originalEffects = { ...observed, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
||||
originalEffects = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline)
|
||||
} catch (error) { originalEffects = effectReadFailure("original-effect-rounds-unavailable", error) }
|
||||
finally { if (roundContext) closeRoutingNetworkForwardRoundConfigurationContext(roundContext) }
|
||||
}
|
||||
@@ -219,10 +231,8 @@ 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()
|
||||
const budget = effectSession.readBudget()
|
||||
originalEffects = { ...originalEffects!, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
||||
sharedPublication = true
|
||||
} catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) }
|
||||
}
|
||||
@@ -231,26 +241,31 @@ 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) {
|
||||
const budget = effectSession.readBudget()
|
||||
originalEffects = { ...originalEffects, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
||||
const { deadline, calls, bytes, rows } = effectSession.readBudget()
|
||||
const budget = originalEffectReportSchema.shape.budget.safeParse({ deadline, calls, bytes, rows })
|
||||
if (budget.success) originalEffects = { ...originalEffects, budget: budget.data }
|
||||
}
|
||||
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }, originalEffects)
|
||||
return verifiedResult(input)
|
||||
} catch { throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN") }
|
||||
} catch (error) {
|
||||
if (!attemptRecorded) throw error
|
||||
throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN")
|
||||
}
|
||||
finally { effectSession?.close() }
|
||||
})),
|
||||
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,7 @@ import { readFile } from "node:fs/promises"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import ts from "typescript"
|
||||
import { z } from "zod"
|
||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||
import { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext } from "@mmapp/contracts/mcp-routing"
|
||||
import { fingerprint, McpError } from "./security.js"
|
||||
|
||||
type Handler = { prepare?: (input: Record<string, unknown>, context: unknown) => Promise<Record<string, unknown>>; execute?: (input: Record<string, unknown>) => Promise<unknown> }
|
||||
@@ -32,10 +32,10 @@ async function fixture() {
|
||||
const profileModule = await load("./routing-profiles.ts", {
|
||||
"./routing-controller-overlap.js": overlap, "./security.js": { fingerprint, McpError, accessGeneration: async () => "g" },
|
||||
"./routing-controller-baseline.js": { prepareRoutingControllerBaseline: async () => { baselinePreparations++; return { packet: {}, proof: "inert-baseline" } }, assertRoutingControllerBaseline: async () => {}, routingControllerBaselinePrincipal: async () => principal, advanceRoutingControllerBaseline: async () => {} },
|
||||
"./routing-community-public.js": { publicRoutingControllerState: () => ({}) }, "node:crypto": { randomUUID }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiProfileSchema }, "../../db/index.js": { pool },
|
||||
"./routing-community-public.js": { publicRoutingControllerState: () => ({}) }, "node:crypto": { randomUUID }, zod: { z }, "@mmapp/contracts/mcp-routing": { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext }, "../../db/index.js": { pool },
|
||||
"./service-registry.js": { registerMcpService: (name: string, handler: Handler) => services.set(name, handler) },
|
||||
"./store.js": { audit: async () => {}, validatePrincipal: async () => connection }, "./routing-model.js": { decideRoutingSwitch: () => ({}) },
|
||||
"./routing-community-context-model.js": { routeContextExecutionReason: () => null },
|
||||
"./path-proof-purpose.js": { COMMUNITY_PATH_MAX_AGE_MS: 90000 },
|
||||
"./routing-controller-fence.js": { saveControllerProfileCas: async () => { casWrites++; return { id: "saved", version: 1 } } },
|
||||
"./routing-reservation-store.js": { createRoutingReservationStore: () => { throw new Error("Overlap-only fixture must not read or mutate routing charges") } },
|
||||
})
|
||||
|
||||
@@ -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,48 +237,95 @@ 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(), {
|
||||
version: 3,
|
||||
version: 4,
|
||||
prepare: async (input, context) => {
|
||||
const sid = Number(input.serverId), { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, "vrf"), current = snapshot.rows.find(row => row[".id"] === input.id)
|
||||
if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения")
|
||||
|
||||
@@ -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; } }
|
||||
@@ -37,7 +37,7 @@ function fixture(service='routing.community') {
|
||||
const principal={connectionId:id(3), credentialId:id(4), credentialExpiresAt:'2026-11-02T13:00:00.000Z'};
|
||||
const authority={packet:{profileId:id(5),profileVersion:7,connectionId:id(3),credentialId:id(4),ownerId:'owner'}, proof:'a'.repeat(64)};
|
||||
const metadata={operationId:id(1),planId:id(2),operationName:'routing.community.switch',operationVersion:7,ownerId:'owner',phase:'forward',stepIndex:0,serviceName:service,principal,policyVersion:2,generation:'generation',originalExpiresAt:now.getTime()+60000,sealedEvidenceProof:sha('evidence'),sealedPayloadProof:sha('payload'),serviceInput:{profile:{autoRecovery:false},persistentProfileAuthority:authority}};
|
||||
const source={operation_state:'running',plan_state:'executing',policy_version:2,generation:'generation',approved_by:'human',observed_at:now,evidence_sealed:'evidence',payload_sealed:'payload',step_state:'intent',intended_state:redact({kind:'service',service,input:metadata.serviceInput})};
|
||||
const source={operation_state:'running',plan_state:'executing',policy_version:2,generation:'generation',approved_by:service==='routing.community.scoped'?'owner':'human',observed_at:now,evidence_sealed:'evidence',payload_sealed:'payload',step_state:'intent',intended_state:redact({kind:'service',service,input:metadata.serviceInput})};
|
||||
const binding={revision:1,operationId:id(1),planId:id(2),stepIndex:0,profileId:id(5),profileVersion:7,connectionId:id(3),ownerId:'owner',principal,service,operationVersion:7,authorityProof:authority.proof,evidenceProof:fingerprint('evidence'),payloadProof:fingerprint('payload'),scope:[1],createdAt:now.toISOString(),chargeUnits:1};
|
||||
const row={source_operation_id:id(1),source_plan_id:id(2),source_step_index:0,profile_id:id(5),connection_id:id(3),charge_units:1,binding_sealed:null,binding_proof:null,created_at:now};
|
||||
let chargeRows=[row], sourceRows=[source], reservedRows=[], decryptCalls=0, nativeWrites=0, keyUnavailable=false;
|
||||
@@ -59,6 +59,7 @@ function fixture(service='routing.community') {
|
||||
run:async()=>{await verifier(db,metadata,'owner');nativeWrites++;},writes:()=>nativeWrites,decryptCalls:()=>decryptCalls};
|
||||
}
|
||||
for(const service of ['routing.community','routing.community.scoped'])test(`exact ${service} running charge passes without mutation`,async()=>{const f=fixture(service);await f.run();assert.equal(f.writes(),1);assert.equal(f.queries.length,3);assert.ok(f.queries.every(({sql})=>sql.startsWith('SELECT ')));assert.ok(f.queries[0].sql.includes('LIMIT 2'));});
|
||||
test('scoped transport refuses delegated approval before reading the charge',async()=>{const f=fixture('routing.community.scoped');f.source.approved_by='delegated-admin';await assert.rejects(f.run);assert.equal(f.writes(),0);assert.equal(f.queries.length,1);});
|
||||
const mutations=[
|
||||
['missing charge',f=>f.noCharge()],['duplicate charge',f=>f.duplicateCharge()],['reserved overlap',f=>f.reserved()],['ambiguous source',f=>f.ambiguousSource()],
|
||||
['pending operation',f=>f.source.operation_state='pending'],['applied operation',f=>f.source.operation_state='applied'],['terminal step',f=>f.source.step_state='verified'],['draft plan',f=>f.source.plan_state='draft'],['missing approval',f=>f.source.approved_by=null],['policy drift',f=>f.source.policy_version++],['generation drift',f=>f.source.generation='other'],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ function cursor(rows:unknown[],onFetch?:()=>void) {
|
||||
test("historical cursor authenticates more than two pages of genuine nonpersistent classifications",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),"mm-manual-history-unit-")),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
||||
try {
|
||||
const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,n%2?6:7)).row)
|
||||
const rows=[];for(let n=0;n<25;n++)rows.push((await fixture(false,6+n%3)).row)
|
||||
const f=cursor(rows)
|
||||
await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{})
|
||||
assert.equal(f.calls.filter(sql=>sql.startsWith("FETCH")).length,3)
|
||||
@@ -76,8 +76,10 @@ test("historical authentication, active persistent refusal, future rejection and
|
||||
await assertLegacyManualHistoryWithinOwner(cursor([f.row]).db,f.profileId,"2026-11-01T13:01:00.001Z",()=>{})
|
||||
const expiredTamper={...f.row,source_evidence:null}
|
||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}))
|
||||
const current={...f.row,recovery_version:7}
|
||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/)
|
||||
for (const version of [7,8]) {
|
||||
const current={...f.row,recovery_version:version}
|
||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([current]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}),/lacks durable charge/)
|
||||
}
|
||||
const stripped={...f.input};delete stripped.persistentProfileAuthority
|
||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([{...f.row,source_evidence:await encryptSecret(JSON.stringify({steps:[{kind:"service",service:"routing.community.scoped",input:stripped}]}))}]).db,f.profileId,at,()=>{}))
|
||||
const future={...f.intent,createdAt:"2026-11-01T12:00:00.001Z"}
|
||||
@@ -112,14 +114,14 @@ test("state transfer cap MODEL refuses oversized or NULL history as a whole, wit
|
||||
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
||||
})
|
||||
|
||||
async function retentionFixture() {
|
||||
const f=await fixture(true,7), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
|
||||
async function retentionFixture(version = 7) {
|
||||
const f=await fixture(true,version), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
|
||||
const payload=await encryptSecret(JSON.stringify({MODEL:true}))
|
||||
const row={...f.row,recovery_payload:payload,authority_profile_version:1}
|
||||
const binding=manualInverseReceiptSchema.shape.binding.parse({revision:1,recoveryOperationId:f.intent.operationId,recoveryPlanId:row.recovery_plan_id,
|
||||
sourceOperationId:f.intent.sourceOperationId,sourcePlanId:row.source_plan_id,sourceStepIndex:0,profileId:f.profileId,profileVersion:1,
|
||||
connectionId:f.intent.connectionId,ownerId:f.intent.ownerId,originalPrincipal:f.input.principal,recoveryPrincipal:f.input.principal,
|
||||
operationVersion:7,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof,
|
||||
operationVersion:version,sourceVersion:7,authorityProof:authority.proof,sourceProof:f.intent.sourceProof,sourceRequestProof:f.input.requestProof,
|
||||
recoveryRequestProof:f.intent.requestSeal,sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),
|
||||
recoveryPayloadProof:fingerprint(payload),scope:authority.packet.scope,createdAt:at,chargeUnits:1})
|
||||
const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")}
|
||||
@@ -136,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]}
|
||||
@@ -153,17 +156,21 @@ function retentionOwner(f:Awaited<ReturnType<typeof retentionFixture>>,present=t
|
||||
test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown noReplay anchor survives',async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'mm-intent2-retention-')),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
||||
try {
|
||||
const f=await retentionFixture(),owner=retentionOwner(f),before=fingerprint(f.row)
|
||||
// SQL aging and the transaction owner below are MODELS, never genuine SQL.
|
||||
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
|
||||
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1})
|
||||
assert.equal(owner.deleted.length,1);assert.equal(fingerprint(f.row),before)
|
||||
for(const profile of [f.profileId,randomUUID()])await assertLegacyManualHistoryWithinOwner(owner.db,profile,'2026-11-03T12:00:00.000Z',()=>{})
|
||||
assert(owner.calls.findIndex(s=>s.includes('pg_advisory_xact_lock'))<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'))
|
||||
assert.equal(f.intent.revision,2);assert.equal(await decryptSecretExisting(f.row.intent_sealed),JSON.stringify(f.intent))
|
||||
for (const version of [7,8]) {
|
||||
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),{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',()=>{})
|
||||
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'))
|
||||
assert.equal(f.intent.revision,2);assert.equal(await decryptSecretExisting(f.row.intent_sealed),JSON.stringify(f.intent))
|
||||
}
|
||||
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
||||
})
|
||||
|
||||
@@ -176,12 +183,17 @@ 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),/hint/)
|
||||
assert.equal(mismatched.deleted.length,0)
|
||||
// Revision2 payload proof cannot be replaced by an authentic stamp from a different payload.
|
||||
const payload=await retentionFixture();payload.row.recovery_payload=await encryptSecret('{}')
|
||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(retentionOwner(payload,false).db,payload.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/payload/)
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function classifyManualInverseHistory(row:HistoricalRow,observedAt:
|
||||
intent.receiptId!==row.recovery_receipt_id || intent.validUntil!==iso(row.deadline) ||
|
||||
row.source_connection_id!==intent.connectionId || row.recovery_connection_id!==intent.connectionId ||
|
||||
row.source_action!=="routing.community.switch" || row.source_version!==7 ||
|
||||
row.recovery_action!=="operations.recover" || ![6,7].includes(row.recovery_version??0) ||
|
||||
row.recovery_action!=="operations.recover" || ![6,7,8].includes(row.recovery_version??0) ||
|
||||
!["partial","applied"].includes(row.source_state??"")) throw fail("Historical manual intent/source identity differs")
|
||||
const source=await sealedJson(row.source_evidence) as ChangeEvidence, recovery=await sealedJson(row.recovery_evidence) as ChangeEvidence
|
||||
const step=source.steps?.[0], inverse=recovery.steps?.[0]
|
||||
@@ -141,7 +141,7 @@ export async function assertLegacyManualHistoryWithinOwner(db:PoolClient,profile
|
||||
// Authenticate any durable inverse row before treating this old manual
|
||||
// identity as represented by the normal profile ledger.
|
||||
const {authenticateManualInverseHistoryWithinOwner}=await import("./routing-manual-inverse-store.js")
|
||||
if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue
|
||||
if(await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal})) continue
|
||||
if(row.recovery_version!==6) throw fail("Current persistent manual inverse lacks durable charge")
|
||||
if(classified.authority.packet.profileId!==profileId) continue
|
||||
// Historical v6 sealed evidence.validUntil bounded admission; source and
|
||||
|
||||
@@ -3,10 +3,11 @@ 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"
|
||||
import { findMcpOperation } from "./registry.js"
|
||||
|
||||
const fail = (message: string) => new McpError("STATE_CONFLICT", message, 409)
|
||||
const uuid = z.string().uuid(), proof = z.string().regex(/^[a-f0-9]{64}$/), stamp = z.string().datetime()
|
||||
@@ -14,7 +15,7 @@ const principal = z.object({ connectionId: uuid, credentialId: uuid, credentialE
|
||||
export const manualInverseBindingSchema = z.object({ revision: z.literal(1), recoveryOperationId: uuid, recoveryPlanId: uuid,
|
||||
sourceOperationId: uuid, sourcePlanId: uuid, sourceStepIndex: z.literal(0), profileId: uuid, profileVersion: z.number().int().positive(),
|
||||
connectionId: uuid, ownerId: z.string().min(1).max(1000), originalPrincipal: principal, recoveryPrincipal: principal,
|
||||
operationVersion: z.literal(7), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof,
|
||||
operationVersion: z.union([z.literal(7), z.literal(8)]), sourceVersion: z.literal(7), authorityProof: proof, sourceProof: proof,
|
||||
sourceRequestProof: proof, recoveryRequestProof: proof, sourceEvidenceProof: proof,
|
||||
recoveryEvidenceProof: proof, recoveryPayloadProof: proof,
|
||||
scope: z.array(z.number().int().positive().safe()).min(1).max(1000), createdAt: stamp, chargeUnits: z.literal(1) }).strict()
|
||||
@@ -74,7 +75,8 @@ export async function selectManualInverseAdmissionWithinOwner(db: PoolClient, pl
|
||||
if (plan.operation_id!=="operations.recover") return undefined
|
||||
const step=evidence.steps[0]
|
||||
if (evidence.steps.length!==1 || step.kind!=="service" || step.service!=="routing.community.scoped.recover") return undefined
|
||||
if (plan.operation_version!==7 || step.input.mode!=="manual") throw fail("Manual recovery action version/mode differs")
|
||||
if (plan.operation_version!==findMcpOperation(plan.operation_id)?.version) throw new McpError("PLAN_EXPIRED", "Версия операции изменилась; подготовьте новый план", 409)
|
||||
if (step.input.mode!=="manual") throw fail("Manual recovery mode differs")
|
||||
const { loadScopedManualRecoveryAdmissionWithinOwner } = await import("./routing-community-scoped-recovery.js")
|
||||
const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db)
|
||||
const { source } = loaded
|
||||
@@ -92,7 +94,7 @@ function expectedBinding(selected: NonNullable<Awaited<ReturnType<typeof selectM
|
||||
return manualInverseBindingSchema.parse({revision:1,recoveryOperationId:operationId,recoveryPlanId:plan.id,
|
||||
sourceOperationId:selected.source.row.id,sourcePlanId:selected.source.row.source_plan_id,sourceStepIndex:0,profileId:authority.profileId,profileVersion:authority.profileVersion,
|
||||
connectionId:actor.connectionId,ownerId:authority.ownerId,originalPrincipal:principal.parse(selected.input.principal),recoveryPrincipal:actor,
|
||||
operationVersion:7,sourceVersion:7,authorityProof:selected.authority.proof,sourceProof:selected.source.sourceProof,
|
||||
operationVersion:plan.operation_version,sourceVersion:selected.source.row.source_version,authorityProof:selected.authority.proof,sourceProof:selected.source.sourceProof,
|
||||
sourceRequestProof:selected.input.requestProof,recoveryRequestProof:selected.recoveryInput.requestProof,
|
||||
sourceEvidenceProof:selected.sourceEvidenceProof,recoveryEvidenceProof:fingerprint(plan.evidence.sealed),
|
||||
recoveryPayloadProof:fingerprint(plan.payload.sealed),scope:authority.scope,createdAt:observedAt,chargeUnits:1})
|
||||
@@ -134,7 +136,7 @@ export async function admitManualInverseWithinOwner(db: PoolClient, operationId:
|
||||
/** A committed charge is required before the existing manual native intent.
|
||||
* This authenticates identity only; existing live/native authority still runs. */
|
||||
export async function assertManualInverseChargeWithinOwner(db:PoolClient,operationId:string,input:Record<string,unknown>,actor:McpPrincipal) {
|
||||
const rows=(await db.query<StoredPlan>("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=7",[operationId,actor.connectionId])).rows
|
||||
const rows=(await db.query<StoredPlan>("SELECT p.* FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2 AND o.state='running' AND p.state='executing' AND p.operation_id='operations.recover' AND p.operation_version=$3",[operationId,actor.connectionId,findMcpOperation("operations.recover")?.version])).rows
|
||||
if(rows.length!==1) throw fail("Manual native claim requires current running action version")
|
||||
const plan=rows[0], evidence=JSON.parse(await decryptSecretExisting(plan.evidence.sealed)) as ChangeEvidence
|
||||
const step=evidence.steps[0]
|
||||
@@ -154,7 +156,7 @@ export async function assertManualInverseChargeWithinOwner(db:PoolClient,operati
|
||||
export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
|
||||
intent:{revision?:number;manualInverseCharge?:ManualInverseReceipt;operationId:string;sourceOperationId:string;sourceReceiptId:string;sourceProof:string;requestSeal:string;connectionId:string;ownerId:string},
|
||||
authority:z.infer<typeof routingProfileAuthorityBindingSchema>,observedAt:string,
|
||||
identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) {
|
||||
identity:{sourceEvidenceProof:string;recoveryEvidenceProof:string;recoveryPlanId:string|null;recoveryVersion:number|null;sourcePlanId:string|null;sourceRequestProof:unknown;sourcePrincipal:unknown;recoveryPrincipal:unknown}) {
|
||||
const rows=(await db.query<ChargeRow>(`SELECT ${columns} FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 OR source_operation_id=$2 LIMIT 3`,[intent.operationId,intent.sourceOperationId])).rows
|
||||
const copy = intent.revision === 2 ? await authenticateCopy(intent.manualInverseCharge, observedAt) : undefined
|
||||
if (!rows.length && !copy) return false
|
||||
@@ -166,7 +168,8 @@ export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
|
||||
value.profileId!==authority.packet.profileId || value.profileVersion!==authority.packet.profileVersion || value.authorityProof!==authority.proof ||
|
||||
value.connectionId!==intent.connectionId || value.ownerId!==intent.ownerId || value.sourceProof!==intent.sourceProof ||
|
||||
value.recoveryRequestProof!==intent.requestSeal || value.sourceEvidenceProof!==identity.sourceEvidenceProof ||
|
||||
value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation")
|
||||
value.recoveryEvidenceProof!==identity.recoveryEvidenceProof || value.recoveryPlanId!==identity.recoveryPlanId ||
|
||||
value.operationVersion!==identity.recoveryVersion || value.sourcePlanId!==identity.sourcePlanId)throw fail("Historical inverse is not represented by its signed allocation")
|
||||
// Only revision2 needs this extra one-row bounded projection; preserve the
|
||||
// existing cursor page payload ceiling and legacy protocol untouched.
|
||||
const payload = copy ? (await db.query<{sealed:string|null}>("SELECT CASE WHEN octet_length(payload::text)<=1048576 THEN payload->>'sealed' END AS sealed FROM mcp_plans WHERE id=$1 LIMIT 2",[identity.recoveryPlanId])).rows : []
|
||||
@@ -181,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
|
||||
@@ -192,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) {
|
||||
@@ -201,11 +204,11 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete
|
||||
" WHERE r.mode='manual' AND r.source_operation_id=$1 AND r.source_step_index=0 AND r.recovery_operation_id=$2",[candidate.source_operation_id,candidate.recovery_operation_id])).rows
|
||||
if(rows.length!==1)throw fail("Retention intent/source pair disappeared")
|
||||
const row=rows[0],classified=await classifyManualInverseHistory(row,observedAt)
|
||||
if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==7 ||
|
||||
if(classified.kind!=="persistent" || classified.intent.revision!==2 || row.recovery_version!==classified.intent.manualInverseCharge.binding.operationVersion ||
|
||||
row.authority_profile_version!==candidate.profile_version || row.authority_profile_version!==classified.intent.manualInverseCharge.binding.profileVersion)
|
||||
throw fail("Retention hint is not an authenticated persistent intent2")
|
||||
if(!await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{
|
||||
sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal}))
|
||||
sourceEvidenceProof:fingerprint(row.source_evidence),recoveryEvidenceProof:fingerprint(row.recovery_evidence),recoveryPlanId:row.recovery_plan_id,recoveryVersion:row.recovery_version,sourcePlanId:row.source_plan_id,sourceRequestProof:classified.sourceRequestProof,sourcePrincipal:classified.sourcePrincipal,recoveryPrincipal:classified.recoveryPrincipal}))
|
||||
throw fail("Retention parent is not authenticated")
|
||||
const result=await db.query("DELETE FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1 AND source_operation_id=$2",[candidate.recovery_operation_id,candidate.source_operation_id])
|
||||
if(result.rowCount!==1)throw fail("Retention parent delete was not confirmed")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { routeAiProfileSchema, type RouteObservation } from "@mmapp/contracts/mcp-routing"
|
||||
import { rankRoutingCandidates, decideRoutingSwitch, type ControllerState, type PathHealth } from "./routing-model.js"
|
||||
import { rankRoutingCandidates, decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth } from "./routing-model.js"
|
||||
|
||||
const now = Date.parse("2026-10-17T10:00:00Z"), at = new Date(now).toISOString()
|
||||
const profile = routeAiProfileSchema.parse({ name: "Test", serverId: 1, community: "65000:10", family: "ipv4", candidates: [
|
||||
@@ -81,6 +81,26 @@ test("failover starts a fresh series after downtime or legacy state and ignores
|
||||
assert.equal(result.state.failureObservedAt, new Date(time).toISOString())
|
||||
}
|
||||
})
|
||||
test("bounded failover probing keeps the highest eligible reserve and eventually visits every other eligible probe", () => {
|
||||
const controlProbe = { targetInterface: "exit-a", expectedHops: [{ serverId: 3, interface: "exit-a" }] }
|
||||
const configured = routeAiProfileSchema.parse({ ...profile, excludedCandidateIds: ["05"],
|
||||
candidates: Array.from({ length: 100 }, (_, index) => ({ ...profile.candidates[0], id: String(index).padStart(2, "0"),
|
||||
controlProbe: index === 4 ? undefined : controlProbe })) })
|
||||
const ranking = rankRoutingCandidates(configured, a, now)
|
||||
const expected = ranking.filter(row => row.eligible && row.candidate.controlProbe && !["00", "01"].includes(row.candidate.id)).map(row => row.candidate.id)
|
||||
let cursor: string | undefined
|
||||
for (const alternate of expected) {
|
||||
const probes = selectFailoverReserveProbes(ranking, "00", cursor)
|
||||
assert.deepEqual(probes.candidateIds, ["01", alternate])
|
||||
assert.equal(probes.cursor, alternate)
|
||||
cursor = probes.cursor
|
||||
}
|
||||
assert.deepEqual(selectFailoverReserveProbes(ranking, "00", cursor).candidateIds, ["01", "02"])
|
||||
assert.deepEqual(selectFailoverReserveProbes(ranking, "00", "05").candidateIds, ["01", "02"])
|
||||
const pinned = rankRoutingCandidates({ ...configured, pinnedCandidateId: "09" }, a, now)
|
||||
assert.deepEqual(selectFailoverReserveProbes(pinned, "00"), { candidateIds: ["09"], cursor: undefined })
|
||||
assert.deepEqual(selectFailoverReserveProbes(pinned, "09"), { candidateIds: [], cursor: undefined })
|
||||
})
|
||||
test("routing combines RTT, independent losses and bottleneck speed, never claims an observed path", () => {
|
||||
const ranked = rankRoutingCandidates(profile, a, now).find(row => row.candidate.id === "a")!
|
||||
assert.equal(ranked.pingMs, 20); assert.ok(Math.abs(ranked.lossPct! - 1.99) < 0.00001)
|
||||
|
||||
@@ -52,6 +52,17 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou
|
||||
|
||||
export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string }
|
||||
export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string }
|
||||
|
||||
export function selectFailoverReserveProbes(ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string, previous?: string) {
|
||||
const standbys = ranking.filter(({ candidate, eligible }) => eligible && candidate.id !== currentCandidateId && candidate.controlProbe)
|
||||
.map(({ candidate }) => candidate.id)
|
||||
const [preferred, ...remaining] = standbys
|
||||
if (remaining.length === 0) return { candidateIds: standbys, cursor: undefined }
|
||||
const index = remaining.indexOf(previous ?? "")
|
||||
const cursor = remaining[(index + 1) % remaining.length]
|
||||
return { candidateIds: [preferred, cursor], cursor }
|
||||
}
|
||||
|
||||
const pathHealthFreshnessSec = 90
|
||||
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) {
|
||||
const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { isIP } from "node:net"
|
||||
|
||||
export function parseRoutingEndpoint(value: unknown) {
|
||||
if (typeof value !== "string" || !value.length || value.length > 256) throw Error("missing-or-unsupported-field")
|
||||
const match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(value)
|
||||
if (!match) throw Error("unsupported-endpoint")
|
||||
const [, address, prefix, interfaceName, vrf] = match
|
||||
const family = isIP(address)
|
||||
if (!family) throw Error("unsupported-address")
|
||||
if (prefix !== undefined && prefix !== String(family === 4 ? 32 : 128)) throw Error("unsupported-peer-range")
|
||||
return { address, interface: interfaceName, vrf }
|
||||
}
|
||||
|
||||
export function matchesBgpSessionContext(
|
||||
row: Record<string, unknown>,
|
||||
expected: { table: string; vrf: string },
|
||||
local: ReturnType<typeof parseRoutingEndpoint>,
|
||||
remote: ReturnType<typeof parseRoutingEndpoint>,
|
||||
) {
|
||||
if (!Object.hasOwn(row, "routing-table") || row["routing-table"] !== expected.table) return false
|
||||
if (Object.hasOwn(row, "vrf")) {
|
||||
return row.vrf === expected.vrf && (local.vrf === undefined || local.vrf === expected.vrf) &&
|
||||
(remote.vrf === undefined || remote.vrf === expected.vrf)
|
||||
}
|
||||
// RouterOS may report the transport VRF only in the two runtime addresses.
|
||||
// The installation table alone cannot identify it.
|
||||
return local.vrf === expected.vrf && remote.vrf === expected.vrf
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -16,6 +16,8 @@ assert(process.env.MCP_STATE_DIR && isAbsolute(process.env.MCP_STATE_DIR))
|
||||
assert.equal(basename(resolve(process.env.MCP_STATE_DIR)), "state")
|
||||
assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||
const originalEffects = process.env.MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS === "true"
|
||||
const controllerFailoverOnly = process.env.MMAPP_OWN_ROUTING_CONTROLLER_FAILOVER_ONLY === "true"
|
||||
assert(!controllerFailoverOnly || originalEffects, "Focused controller failover requires the owned original-effects fixture")
|
||||
assert(!(originalEffects && process.env.MMAPP_OWN_ROUTING_WORKER_REPRO === "true"), "Owned effect acceptance cannot mix with the worker repro profile")
|
||||
process.env.AUTH_REQUIRED = "true"
|
||||
process.env.AUTH_JWT_SECRET = "owned-routing-forward-rounds-acceptance-secret"
|
||||
@@ -35,7 +37,7 @@ try {
|
||||
console.log("Worker fixture repro:",await (await import("./routing-worker-fixture-isolation.sql-helper.js")).acceptRoutingWorkerFixtureIsolation(actor))
|
||||
}
|
||||
const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor,
|
||||
originalEffects ? { originalEffectsOnly: true } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true })
|
||||
originalEffects ? { originalEffectsOnly: true, controllerFailoverOnly } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true })
|
||||
assert.equal(result.accepted, true)
|
||||
} finally {
|
||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||
|
||||
@@ -154,6 +154,25 @@ for (const mutation of ["duplicate-config", "wrong-config-id", "unresolved-templ
|
||||
assert.equal(f.parser.parseBgp("peer1", raw).status, "unknown")
|
||||
})
|
||||
|
||||
for (const ipv6 of [false, true]) test("BGP runtime context can come from both exact qualified " + (ipv6 ? "IPv6" : "IPv4") + " endpoints", () => {
|
||||
const f = fixture(ipv6), raw = structuredClone(f.bgp) as unknown as RawMutation, session = raw.payload[0]
|
||||
const local = session["local.address"], remote = session["remote.address"]
|
||||
delete session.vrf
|
||||
session["local.address"] = `${local}@main`
|
||||
session["remote.address"] = `${remote}@main`
|
||||
const observation = f.parser.parseBgp("peer1", raw)
|
||||
assert.equal(observation.status, "observed", JSON.stringify(observation))
|
||||
assert.equal(observation.securityProof, false)
|
||||
for (const mutation of [
|
||||
{ "remote.address": remote }, { "remote.address": `${remote}@tenant` },
|
||||
{ "remote.address": `${remote}@main@main` }, { "routing-table": "main" },
|
||||
{ vrf: "tenant" }, { vrf: null },
|
||||
]) {
|
||||
assert.equal(f.parser.parseBgp("peer1", { ...raw, payload: [{ ...session, ...mutation }] }).status, "unknown")
|
||||
}
|
||||
assert.equal(f.parser.parseBgp("peer1", { ...raw, payload: [session, { ...session, ".id": "*B3" }] }).status, "unknown")
|
||||
})
|
||||
|
||||
test("native cached non-established BGP cannot invent a model FSM state from an undocumented field", () => {
|
||||
const f = fixture(), raw = structuredClone(f.bgp) as unknown as RawMutation
|
||||
raw.payload[0].established = "false"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isIP } from "node:net"
|
||||
import { z } from "zod"
|
||||
import { canonicalBgpPolicyPrefix } from "../../services/bgp-community-policy-model.js"
|
||||
import { captureRoutingNetworkManifest, type RoutingNetworkRound } from "./routing-network-verifier-model.js"
|
||||
import { matchesBgpSessionContext, parseRoutingEndpoint } from "./routing-native-endpoint.js"
|
||||
|
||||
/** Pure decoded-response parsers, NOT source authentication or completeness
|
||||
* proof. Only the explicit RouterOS7.20–7.26 field subset below is recognized.
|
||||
@@ -32,11 +33,8 @@ function canonicalAddress(value: unknown): string {
|
||||
return canonicalBgpPolicyPrefix(text + "/" + (family === 4 ? 32 : 128)).split("/")[0]
|
||||
}
|
||||
function endpoint(value: unknown) {
|
||||
const text = literal(value), match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(text)
|
||||
if (!match) return fail("unsupported-endpoint")
|
||||
const address = canonicalAddress(match[1])
|
||||
if (match[2] !== undefined && match[2] !== String(isIP(address) === 4 ? 32 : 128)) return fail("unsupported-peer-range")
|
||||
return { address, interface: match[3], vrf: match[4] }
|
||||
const parsed = parseRoutingEndpoint(value)
|
||||
return { ...parsed, address: canonicalAddress(parsed.address) }
|
||||
}
|
||||
function decodedRows(payload: unknown, maximum = 1000, ids = true): Row[] {
|
||||
let nodes = 0
|
||||
@@ -144,7 +142,7 @@ export function captureRoutingNetworkNativeParser(rawManifest: unknown) {
|
||||
if (instance !== wanted.instance) continue
|
||||
if (local.address !== wanted.localAddress || remote.address !== wanted.remoteAddress) continue
|
||||
if (integer(row["remote.as"], 4294967295) !== wanted.remoteAsn || !afi(row["remote.afi"]).includes(expectedAfi) || !afi(row["local.afi"]).includes(expectedAfi)) return unknown("bgp-session-asn-or-afi-drift")
|
||||
if (row.vrf !== binding.vrf || row["routing-table"] !== binding.table || [local.vrf, remote.vrf].some(vrf => vrf !== undefined && vrf !== binding.vrf) || local.interface !== binding.local.interface || remote.interface !== binding.remote.interface) return unknown("bgp-session-context-drift")
|
||||
if (!matchesBgpSessionContext(row, binding, local, remote) || local.interface !== binding.local.interface || remote.interface !== binding.remote.interface) return unknown("bgp-session-context-drift")
|
||||
relevant.push(row)
|
||||
}
|
||||
if (relevant.length !== 1) return unknown("missing-or-ambiguous-bgp-session")
|
||||
|
||||
@@ -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();
|
||||
|
||||
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user